You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用纯JS实现textPath效果 无transform属性沿路径排布弧形文字

无transform依赖的沿路径排布文字实现方案

需要实现不依赖transform属性的弧形/自定义路径排布文字时,原生SVG <textPath> 因为仅在渲染层做临时变换、不输出固化好坐标的纯路径,无法满足需求。

原生<textPath>底层实现逻辑

原生方案没有特殊黑魔法,核心就是「路径采样+逐字形刚体变换」流程:

  • 解析目标路径,预计算路径总长度,支持按弧长查询任意位置的坐标、切线方向
  • 按水平排版规则计算整段文本的总宽度,结合startOffset、对齐规则确定第一个字符在路径上的起始弧长位置
  • 逐字符遍历,按字符字宽累加已占用弧长,匹配当前字符对应的路径锚点,将原本在水平基线上的字形做平移、旋转,贴到路径对应位置
  • 所有字形的临时变换结果仅在渲染时生效,不会修改路径本身的坐标值

核心实现逻辑

选择forEachGlyph作为字形获取入口的思路完全正确,缺失的核心部分是目标路径预处理、逐字形坐标变换两个模块,不需要引入额外第三方路径库,直接复用opentype.js自带的路径计算能力即可实现,流程如下:

  1. 解析输入的目标SVG路径,利用opentype.js的Path内置方法,支持查询路径总长度、任意弧长位置的坐标点
  2. 预排版计算:遍历所有字形,按字号缩放比例计算每个字符的实际宽度、整段文本总宽度,根据对齐规则(居左/居中/居右)计算第一个字符在路径上的起始弧长偏移
  3. 逐字符处理:
    • 维护当前已消耗的弧长游标,对每个字符取其中心点对应的弧长位置,查询该位置的路径坐标、切线旋转角
    • 取出当前字符的原始字形路径,遍历路径所有绘制命令,对命令中的每个坐标点依次做「原点对齐→旋转→平移到目标锚点」三步变换
    • 将变换后的路径追加到最终总路径中,累加弧长游标处理下一个字符

可直接复用的实现代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 22:54:08