如何用纯JS实现textPath效果 无transform属性沿路径排布弧形文字
无transform依赖的沿路径排布文字实现方案
需要实现不依赖transform属性的弧形/自定义路径排布文字时,原生SVG <textPath> 因为仅在渲染层做临时变换、不输出固化好坐标的纯路径,无法满足需求。
原生<textPath>底层实现逻辑
原生方案没有特殊黑魔法,核心就是「路径采样+逐字形刚体变换」流程:
- 解析目标路径,预计算路径总长度,支持按弧长查询任意位置的坐标、切线方向
- 按水平排版规则计算整段文本的总宽度,结合
startOffset、对齐规则确定第一个字符在路径上的起始弧长位置 - 逐字符遍历,按字符字宽累加已占用弧长,匹配当前字符对应的路径锚点,将原本在水平基线上的字形做平移、旋转,贴到路径对应位置
- 所有字形的临时变换结果仅在渲染时生效,不会修改路径本身的坐标值
核心实现逻辑
选择forEachGlyph作为字形获取入口的思路完全正确,缺失的核心部分是目标路径预处理、逐字形坐标变换两个模块,不需要引入额外第三方路径库,直接复用opentype.js自带的路径计算能力即可实现,流程如下:
- 解析输入的目标SVG路径,利用opentype.js的Path内置方法,支持查询路径总长度、任意弧长位置的坐标点
- 预排版计算:遍历所有字形,按字号缩放比例计算每个字符的实际宽度、整段文本总宽度,根据对齐规则(居左/居中/居右)计算第一个字符在路径上的起始弧长偏移
- 逐字符处理:
- 维护当前已消耗的弧长游标,对每个字符取其中心点对应的弧长位置,查询该位置的路径坐标、切线旋转角
- 取出当前字符的原始字形路径,遍历路径所有绘制命令,对命令中的每个坐标点依次做「原点对齐→旋转→平移到目标锚点」三步变换
- 将变换后的路径追加到最终总路径中,累加弧长游标处理下一个字符
可直接复用的实现代码
import opentype from 'opentype.js'; import { Path } from 'opentype.js'; const textPath = (props: { text: string; path: string; font: Buffer; fontSize: number; align?: 'left' | 'center' | 'right'; }): string => { const font = opentype.parse(props.font.buffer); const fontScale = props.fontSize / font.unitsPerEm; // 加载目标排布路径 const targetPath = new Path(); targetPath.fromSVG(props.path); const pathTotalLength = targetPath.getTotalLength(); // 预计算字形尺寸与文本总宽度 const glyphList = font.stringToGlyphs(props.text); let textTotalWidth = 0; const glyphMeta = glyphList.map(glyph => { const charWidth = glyph.advanceWidth * fontScale; textTotalWidth += charWidth; return { glyph, charWidth }; }); // 计算路径起始偏移,默认居中对齐 let currentOffset = 0; if (!props.align || props.align === 'center') { currentOffset = (pathTotalLength - textTotalWidth) / 2; } else if (props.align === 'right') { currentOffset = pathTotalLength - textTotalWidth; } const finalPath = new Path(); for (const { glyph, charWidth } of glyphMeta) { const charAnchorOffset = currentOffset + charWidth / 2; // 超出路径长度的字符直接跳过 if (charAnchorOffset < 0 || charAnchorOffset > pathTotalLength) { currentOffset += charWidth; continue; } // 获取锚点坐标与切线旋转角 const anchorPos = targetPath.getPointAtLength(charAnchorOffset); const deltaPos = targetPath.getPointAtLength(Math.min(charAnchorOffset + 1, pathTotalLength)); const rotateRad = Math.atan2(deltaPos.y - anchorPos.y, deltaPos.x - anchorPos.x); const cosVal = Math.cos(rotateRad); const sinVal = Math.sin(rotateRad); // 逐点变换原始字形路径 const rawGlyphPath = glyph.getPath(0, 0, props.fontSize); const transformedGlyphPath = new Path(); rawGlyphPath.commands.forEach(cmd => { // 单坐标点变换逻辑 const transPoint = (x: number, y: number) => { // 1. 将字形水平中心点移到坐标原点 const relX = x - charWidth / 2; const relY = y; // 2. 按路径切线角度旋转 const rotatedX = relX * cosVal - relY * sinVal; const rotatedY = relX * sinVal + relY * cosVal; // 3. 平移到路径对应锚点 return { x: rotatedX + anchorPos.x, y: rotatedY + anchorPos.y }; }; switch(cmd.type) { case 'M': const mP = transPoint(cmd.x, cmd.y); transformedGlyphPath.moveTo(mP.x, mP.y); break; case 'L': const lP = transPoint(cmd.x, cmd.y); transformedGlyphPath.lineTo(lP.x, lP.y); break; case 'Q': const qCtrl = transPoint(cmd.x1, cmd.y1); const qP = transPoint(cmd.x, cmd.y); transformedGlyphPath.quadraticCurveTo(qCtrl.x, qCtrl.y, qP.x, qP.y); break; case 'C': const cCtrl1 = transPoint(cmd.x1, cmd.y1); const cCtrl2 = transPoint(cmd.x2, cmd.y2); const cP = transPoint(cmd.x, cmd.y); transformedGlyphPath.bezierCurveTo(cCtrl1.x, cCtrl1.y, cCtrl2.x, cCtrl2.y, cP.x, cP.y); break; case 'Z': transformedGlyphPath.closePath(); break; } }); finalPath.extend(transformedGlyphPath); currentOffset += charWidth; } return finalPath.toPathData(3); };
优化提示
- 该方案输出的是纯坐标构成的路径数据,完全不包含
transform属性,渲染效果和原生<textPath>一致 - 如果需要文字和路径保持固定间距,可以在最后一步坐标平移时,沿路径法线方向(切线角度加90度)叠加对应偏移量即可
- 常规字号下逐字符做刚体旋转平移的效果已经足够平滑,如果遇到超大字号、路径曲率极高的场景,可以将单个字符进一步拆分为多段做逐段变形,消除视觉畸变
内容的提问来源于stack exchange,提问作者Lachlan Smith
相关产品推荐
相关产品推荐

