前言

如果要对文字做描边、路径动画或沿轮廓绘制图形,首先需要拿到字形的矢量轮廓。measureText 只能提供排版尺寸,不能替代路径数据。HarmonyOS 7 的 @kit.ArkGraphics2D 提供了 drawing.Font.getTextPath()textToGlyphs()createPathForGlyph(),分别覆盖整段文字和单个 glyph 的路径提取场景。

效果演示

在这里插入图片描述

项目准备

创建工程

在 DevEco Studio 中新建 Empty Ability 工程,选择 Stage 模型,API 版本设为 26(HarmonyOS7)。本文代码使用 ArkTS。

配置说明

本案例使用 @kit.ArkGraphics2D 的 drawing 模块,不需要额外的权限声明,也不需要特殊的 module.json5 配置。drawing.Font 使用系统默认字体,无需加载自定义字体文件。

检查清单

检查项要求出错表现
API 版本API 26无法识别 drawing.Fontdrawing.Path 等类型
Canvas 组件使用 DrawingRenderingContext用错 CanvasRenderingContext2D 会导致 API 不兼容
byteLength 参数必须精确匹配文本字节长度getTextPath 返回空路径或抛异常

核心实现

导入与常量

import { drawing, common2D } from '@kit.ArkGraphics2D';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { buffer } from '@kit.ArkTS';

各 Kit 的职责:

  • @kit.ArkGraphics2D:核心——drawing 模块提供 FontPathCanvasPenBrush 等矢量绘图类型;common2D 提供 Rect 等基础 2D 数据结构
  • @kit.ArkTSbuffer 模块用于计算文本的字节长度——getTextPath 要求精确的 byteLength 参数
  • @kit.PerformanceAnalysisKit:日志输出
const DOMAIN = 0x0000;
const TAG = 'FontPathDemo';

数据结构

interface GlyphInfo {
  char: string;
  glyphIndex: number;
  width: number;
  bounds: common2D.Rect;
}

每个字符的完整信息:

  • char:原始字符,用于界面展示
  • glyphIndex:字体的 glyph 编号。glyph index 是字体内部的字符标识,和 Unicode 码点不是一回事——同一字符在不同字体中可能有不同的 glyph index
  • width:字符的水平前进宽度(advance width),即排版时该字符占据的水平空间
  • bounds:字符的边界矩形,由 font.getBounds(glyphs) 返回

组件状态

@Entry
@Component
struct A4 {
  @State inputText: string = '鸿蒙HarmonyOS';
  @State fontSize: number = 80;
  @State strokeOnly: boolean = false;
  @State glyphInfos: GlyphInfo[] = [];
  @State currentIndex: number = 0;
  @State charCount: number = 0;

  private mainCtx: DrawingRenderingContext = new DrawingRenderingContext();
  private glyphCtx: DrawingRenderingContext = new DrawingRenderingContext();

状态分三组:

  • 输入控制inputText(待渲染文本)、fontSize(字号)、strokeOnly(是否仅描边)
  • 字形数据glyphInfos(逐字信息数组)、currentIndex(当前选中字符索引)、charCount(字符总数)
  • 绘图上下文mainCtx(整体路径 Canvas)、glyphCtx(单字路径 Canvas),private 修饰不触发 UI 刷新

字节长度计算:为什么 getTextPath 需要它

  private getByteLength(text: string): number {
    return buffer.from(text).length;
  }

drawing.Font.getTextPath(text, byteLength, x, y) 的第二个参数 byteLength 是文本的字节长度,不是字符长度。JavaScript 的 string.length 返回的是 UTF-16 码元数量,而 getTextPath 需要 UTF-8 字节长度。

为什么这很重要?举几个例子:

字符串.lengthUTF-8 字节长度说明
"ABC"33纯 ASCII,两者一致
"鸿蒙"26每个汉字 UTF-8 占 3 字节
"HarmonyOS"99纯 ASCII
"鸿蒙HarmonyOS"12156 + 9

如果传 text.length 而不是字节长度,中英文混合字符串的路径生成会失败——API 按字节数截断文本,截断位置可能在某个多字节字符的中间,导致路径为空或乱码。buffer.from(text).length 用 Node.js 兼容的 Buffer API 正确计算 UTF-8 字节长度。

整体文字路径渲染

  private drawPathOnCanvas(): void {
    let canvas: drawing.Canvas | undefined = this.mainCtx.canvas;
    if (!canvas) {
      return;
    }
    canvas.clear({ alpha: 0, red: 0, green: 0, blue: 0 });

DrawingRenderingContext.canvas 获取底层 drawing.Canvas,canvas.clear 清空画布(全透明黑色)。每次重绘前必须 clear,否则新路径会叠加在旧路径上。

字体配置
    let font: drawing.Font = new drawing.Font();
    font.setSize(this.fontSize);
    font.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS);
    font.setHinting(drawing.FontHinting.FULL);
  • setSize:设置字号(像素)。和 CSS 的 font-size 概念一致
  • setEdging(SUBPIXEL_ANTI_ALIAS):子像素抗锯齿,在 LCD 屏幕上利用子像素排列提升边缘平滑度。还有 ANTI_ALIAS(普通抗锯齿)和 ALIAS(无抗锯齿)两个选项。文字路径渲染建议用 SUBPIXEL_ANTI_ALIAS,效果最好
  • setHinting(FULL):完整字体微调(hinting),利用字体内置的指令在小字号下优化字形。还有 NONE(无微调)和 SLIGHT(轻微微调)
路径提取
    let path: drawing.Path | undefined = undefined;
    try {
      let byteLen: number = this.getByteLength(this.inputText);
      path = font.getTextPath(this.inputText, byteLen, 0, this.fontSize);
    } catch (e) {
      hilog.error(DOMAIN, TAG, `getTextPath failed`);
      this.mainCtx.invalidate();
      return;
    }

font.getTextPath(text, byteLength, x, y) 的四个参数:

  • text:要渲染的文本字符串
  • byteLength:文本的 UTF-8 字节长度(前文已详述)
  • xy:文本基线的起始坐标。y = this.fontSize 表示基线在画布顶部向下一个字号的位置,这是常见的文字布局起点

返回值是 drawing.Path | undefined——提取失败时返回 undefined。失败的原因通常是 byteLength 不匹配或文本为空。

两种渲染模式
    if (this.strokeOnly) {
      let pen: drawing.Pen = new drawing.Pen();
      pen.setStrokeWidth(2);
      pen.setColor({ alpha: 255, red: 139, green: 92, blue: 246 });
      pen.setAntiAlias(true);
      canvas.attachPen(pen);
      canvas.drawPath(path);
      canvas.detachPen();
    } else {
      let brush: drawing.Brush = new drawing.Brush();
      brush.setColor({ alpha: 255, red: 99, green: 102, blue: 241 });
      brush.setAntiAlias(true);
      canvas.attachBrush(brush);

      let pen: drawing.Pen = new drawing.Pen();
      pen.setStrokeWidth(1.5);
      pen.setColor({ alpha: 255, red: 139, green: 92, blue: 246 });
      pen.setAntiAlias(true);
      canvas.attachPen(pen);

      canvas.drawPath(path);

      canvas.detachBrush();
      canvas.detachPen();
    }

    this.mainCtx.invalidate();

ArkGraphics2D 的绘图模型:Pen 负责描边,Brush 负责填充。两者可以同时附加到 Canvas,drawPath 时先填充后描边。

  • 纯描边模式:只 attachPen,不 attachBrush。紫色描边(#8B5CF6),线宽 2px
  • 填充+描边模式:同时 attachBrush + attachPen。蓝色填充(#6366F1)+ 紫色描边(#8B5CF6),线宽 1.5px。填充色和描边色用不同色调,让轮廓线清晰可见

canvas.invalidate() 通知 Canvas 组件刷新显示。drawing.Canvas 的操作不会自动触发 UI 更新,必须手动调用 invalidate

单字路径渲染:自动居中缩放

这是本案例最复杂的绘图方法,核心难点是"不同字符的 bounding box 差异极大,如何在固定画布中居中显示"。

  private drawGlyphOnCanvas(): void {
    let canvas: drawing.Canvas | undefined = this.glyphCtx.canvas;
    if (!canvas) {
      return;
    }
    canvas.clear({ alpha: 0, red: 0, green: 0, blue: 0 });

    if (this.glyphInfos.length === 0 || this.currentIndex >= this.glyphInfos.length) {
      this.glyphCtx.invalidate();
      return;
    }

    let info: GlyphInfo = this.glyphInfos[this.currentIndex];
    let font: drawing.Font = new drawing.Font();
    font.setSize(200);
    font.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS);

单字预览使用 200px 大字号,保证路径精度足够。字号越大,路径中的贝塞尔曲线控制点越精确,缩放后边缘越平滑。

glyph index → Path
    let glyphs: number[] = font.textToGlyphs(info.char);
    if (glyphs.length === 0 || glyphs[0] === 0) {
      this.glyphCtx.invalidate();
      return;
    }

    let glyphPath: drawing.Path | undefined = undefined;
    try {
      glyphPath = font.createPathForGlyph(glyphs[0]);
    } catch (e) {
      hilog.error(DOMAIN, TAG, `createPathForGlyph failed`);
      this.glyphCtx.invalidate();
      return;
    }

两步提取单字路径:

  1. font.textToGlyphs(info.char):将单个字符转为 glyph index 数组。一个字符通常对应一个 glyph,但合字(ligature)可能一对多。glyphs[0] === 0 表示该字符在当前字体中没有对应的 glyph(字体不包含该字符),此时跳过
  2. font.createPathForGlyph(glyphs[0]):用 glyph index 提取独立路径。和 getTextPath 不同,这个路径的坐标原点是字形的本地原点(通常是基线左端),不受 x/y 参数影响
自动居中缩放算法
    let rect: common2D.Rect = glyphPath.getBounds();
    let pathW: number = rect.right - rect.left;
    let pathH: number = rect.bottom - rect.top;
    if (pathW <= 0 || pathH <= 0) {
      this.glyphCtx.invalidate();
      return;
    }

    let canvasW: number = 340;
    let canvasH: number = 220;
    let padding: number = 16;
    let availW: number = canvasW - padding * 2;
    let availH: number = canvasH - padding * 2;
    let scale: number = Math.min(availW / pathW, availH / pathH);
    let offsetX: number = padding + (availW - pathW * scale) / 2 - rect.left * scale;
    let offsetY: number = padding + (availH - pathH * scale) / 2 - rect.top * scale;

这段代码解决的核心问题:glyphPath 的坐标空间和画布坐标空间不一致,需要变换使其居中显示

逐步拆解:

  1. glyphPath.getBounds():获取路径的轴对齐边界矩形。rect.left/top 是路径最左/最上方的坐标,rect.right/bottom 是最右/最下方。注意 rect.left 可能是负数(比如字符向左延伸超出原点),rect.top 也可能是负数(比如字符有上伸部如"b"的竖线顶部高于基线)

  2. 计算缩放比scale = Math.min(availW / pathW, availH / pathH)。取宽高缩放比的较小值,保证字符完整显示在画布内(等比缩放,不变形)

  3. 计算偏移量:分两部分理解——

    • padding + (availW - pathW * scale) / 2:居中偏移——可用空间减去缩放后的路径宽度,剩余空间左右各分一半
    • - rect.left * scale:原点修正——路径的 left 可能不是 0,缩放后原点偏移量也要按比例修正。不加这个修正,字符会偏离中心
Canvas 变换
    canvas.save();
    canvas.translate(offsetX, offsetY);
    canvas.scale(scale, scale);

    let brush: drawing.Brush = new drawing.Brush();
    brush.setColor({ alpha: 255, red: 16, green: 185, blue: 129 });
    brush.setAntiAlias(true);
    canvas.attachBrush(brush);

    let pen: drawing.Pen = new drawing.Pen();
    pen.setStrokeWidth(2 / scale);
    pen.setColor({ alpha: 255, red: 5, green: 150, blue: 105 });
    pen.setAntiAlias(true);
    canvas.attachPen(pen);

    canvas.drawPath(glyphPath);

    canvas.detachBrush();
    canvas.detachPen();
    canvas.restore();
    this.glyphCtx.invalidate();
  }

canvas.save() 保存当前变换状态 → translate 平移到居中位置 → scale 统一缩放 → 绘制 → canvas.restore() 恢复变换状态。save/restore 是防止变换矩阵污染后续绘制的标准做法。

一个关键细节:pen.setStrokeWidth(2 / scale)。为什么除以 scale?因为 canvas.scale(scale, scale) 会同时放大线宽——如果 scale = 4,原本 2px 的线会变成 8px,粗得不像描边而像填充。2 / scale 让缩放后的实际线宽保持在 2px。

单字预览用绿色系(填充 #10B981 + 描边 #059669),和整体路径的紫色系形成视觉区分。

字形信息更新

  private updateGlyphInfos(): void {
    try {
      let font: drawing.Font = new drawing.Font();
      font.setSize(50);
      let glyphs: number[] = font.textToGlyphs(this.inputText);
      let widths: number[] = font.getWidths(glyphs);
      let bounds: Array<common2D.Rect> = font.getBounds(glyphs);

      let infos: GlyphInfo[] = [];
      for (let i = 0; i < glyphs.length; i++) {
        let charStr: string = i < this.inputText.length ? this.inputText[i] : '?';
        infos.push({
          char: charStr,
          glyphIndex: glyphs[i],
          width: widths[i],
          bounds: bounds[i],
        });
      }
      this.glyphInfos = infos;
      this.charCount = infos.length;
      if (this.currentIndex >= infos.length) {
        this.currentIndex = infos.length > 0 ? infos.length - 1 : 0;
      }
    } catch (e) {
      hilog.error(DOMAIN, TAG, `updateGlyphInfos failed`);
      this.glyphInfos = [];
      this.charCount = 0;
    }
  }

三个 API 的调用顺序:

  1. font.textToGlyphs(text):输入文本字符串,输出 glyph index 数组。返回数组的长度通常等于文本的字符数,但合字和 emoji 可能导致长度不同
  2. font.getWidths(glyphs):输入 glyph index 数组,输出每个 glyph 的 advance width 数组。这个宽度是字号相关的——字号 50px 下的宽度
  3. font.getBounds(glyphs):输入 glyph index 数组,输出每个 glyph 的边界矩形数组。矩形坐标也是相对于字号 50px 的

一个边界处理:charStr: string = i < this.inputText.length ? this.inputText[i] : '?'textToGlyphs 返回的数组长度可能超过 inputText.length(比如 emoji 在 JavaScript 中占 2 个 UTF-16 码元但只有 1 个 glyph),此时用 ? 兜底避免越界。

currentIndex 超出新的 infos.length 时,自动回退到最后一个有效索引,避免空指针。

字符导航

  private prevChar(): void {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      this.drawGlyphOnCanvas();
    }
  }

  private nextChar(): void {
    if (this.currentIndex < this.glyphInfos.length - 1) {
      this.currentIndex++;
      this.drawGlyphOnCanvas();
    }
  }

切换字符时,更新索引并重绘单字路径。边界检查防止越界——到头了就不响应。

UI 布局:标题栏

  @Builder
  headerBar() {
    Row() {
      Text('字体轮廓路径')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Text(`${this.glyphInfos.length} 字形`)
        .fontSize(12)
        .fontColor('#6366F1')
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .alignItems(VerticalAlign.Center)
    .backgroundColor('#0F0F23')
  }

标题居中,右侧显示当前文本解析出的字形总数。glyphInfos.lengthinputText.length 可能不同(合字/emoji),用"字形"而非"字符"更准确。

UI 布局:输入区

  @Builder
  inputSection() {
    Column() {
      TextInput({ placeholder: '输入文本获取轮廓路径...', text: this.inputText })
        .width('100%')
        .height(44)
        .fontSize(15)
        .fontColor('#FFFFFF')
        .placeholderColor('#555577')
        .backgroundColor('#1A1A33')
        .borderRadius(22)
        .padding({ left: 20, right: 20 })
        .caretColor('#6366F1')
        .onChange((value: string) => {
          this.inputText = value;
          this.currentIndex = 0;
          this.drawPathOnCanvas();
          this.updateGlyphInfos();
          this.drawGlyphOnCanvas();
        })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16, bottom: 12 })
    .backgroundColor('#141428')
  }

文本变化时依次执行三个操作:重绘整体路径 → 更新字形信息数组 → 重绘单字路径。currentIndex 重置为 0,回到第一个字符。

UI 布局:路径预览卡片

  @Builder
  pathPreviewCard() {
    Column() {
      Row() {
        Text('文字轮廓路径')
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
        Blank()
        Text(this.strokeOnly ? '描边模式' : '填充+描边')
          .fontSize(11)
          .fontColor('#6366F1')
          .onClick(() => {
            this.strokeOnly = !this.strokeOnly;
            this.drawPathOnCanvas();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 4 })

      Canvas(this.mainCtx)
        .width('100%')
        .height(120)
        .backgroundColor('#0A0A1A')
        .borderRadius(12)
        .onReady(() => {
          this.drawPathOnCanvas();
        })

      Row() {
        Text('字号')
          .fontSize(12)
          .fontColor('#AAAACC')
          .width(36)
        Slider({ value: this.fontSize, min: 20, max: 200, step: 10 })
          .layoutWeight(1)
          .trackColor('#1A1A33')
          .selectedColor('#6366F1')
          .blockColor('#FFFFFF')
          .onChange((value: number) => {
            this.fontSize = value;
            this.drawPathOnCanvas();
          })
        Text(`${this.fontSize}px`)
          .fontSize(11)
          .fontColor('#6366F1')
          .width(48)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 12 })
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .backgroundColor('#0F0F23')
    .borderRadius(16)
    .margin({ left: 16, right: 16, top: 8 })
    .shadow({ radius: 8, color: '#00000033', offsetY: 2 })
  }

路径预览卡片分三层:

  1. 标题行:左侧"文字轮廓路径",右侧渲染模式标签(可点击切换)
  2. Canvas:使用 DrawingRenderingContext 的 Canvas 组件,高度 120。onReady 回调在 Canvas 初始化完成后触发,首次绘制
  3. 字号控制:Slider 控制字号 20~200px,步长 10

注意这里用的是 Canvas(this.mainCtx) 配合 DrawingRenderingContext,而不是 Canvas(this.mainCtx) 配合 CanvasRenderingContext2D。两者的 API 完全不同——DrawingRenderingContext 暴露 drawing.Canvas,支持 Pen/Brush/Path 等 ArkGraphics2D 原生类型;CanvasRenderingContext2D 暴露 Web 标准的 Canvas 2D API。本案例需要 drawing.Path,必须用 DrawingRenderingContext

UI 布局:逐字路径卡片

  @Builder
  charStripCard() {
    Column() {
      Text('逐字路径 (左右滑动切换)')
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .fontColor('#FFFFFF')
        .width('100%')
        .padding({ left: 16, top: 12, bottom: 4 })

      if (this.glyphInfos.length > 0) {
        Row() {
          Row() {
            Text('<')
              .fontSize(18)
              .fontColor(this.currentIndex > 0 ? '#6366F1' : '#333355')
              .fontWeight(FontWeight.Bold)
          }
          .width(36)
          .height(36)
          .justifyContent(FlexAlign.Center)
          .alignItems(VerticalAlign.Center)
          .backgroundColor('#1A1A33')
          .borderRadius(18)
          .onClick(() => this.prevChar())

          Scroll() {
            Row() {
              ForEach(this.glyphInfos, (info: GlyphInfo, index: number) => {
                Column() {
                  Text(info.char)
                    .fontSize(18)
                    .fontColor(index === this.currentIndex ? '#FFFFFF' : '#888899')
                    .fontWeight(index === this.currentIndex ? FontWeight.Bold : FontWeight.Normal)
                }
                .width(36)
                .height(36)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(index === this.currentIndex ? '#6366F1' : '#1A1A33')
                .borderRadius(8)
                .margin({ left: 4, right: 4 })
                .onClick(() => {
                  this.currentIndex = index;
                  this.drawGlyphOnCanvas();
                })
              }, (_info: GlyphInfo, index: number) => `${index}`)
            }
          }
          .layoutWeight(1)
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)

          Row() {
            Text('>')
              .fontSize(18)
              .fontColor(this.currentIndex < this.glyphInfos.length - 1 ? '#6366F1' : '#333355')
              .fontWeight(FontWeight.Bold)
          }
          .width(36)
          .height(36)
          .justifyContent(FlexAlign.Center)
          .alignItems(VerticalAlign.Center)
          .backgroundColor('#1A1A33')
          .borderRadius(18)
          .onClick(() => this.nextChar())
        }
        .width('100%')
        .padding({ left: 12, right: 12, bottom: 8 })
        .alignItems(VerticalAlign.Center)

字符选择条:左侧"<“按钮 + 中间横向滚动字符列表 + 右侧”>"按钮。当前选中字符紫色高亮,其他字符灰色。点击任意字符切换选中并重绘单字路径。

两端按钮在到达边界时变灰(#333355),视觉提示无法继续。

        Column() {
          Canvas(this.glyphCtx)
            .width('100%')
            .height(220)
            .backgroundColor('#0A0A1A')
            .borderRadius(12)
            .onReady(() => {
              this.drawGlyphOnCanvas();
            })

          Row() {
            Text(this.glyphInfos[this.currentIndex].char)
              .fontSize(16)
              .fontColor('#10B981')
              .fontWeight(FontWeight.Bold)
            Text(`  glyph #${this.glyphInfos[this.currentIndex].glyphIndex}`)
              .fontSize(11)
              .fontColor('#888899')
            Text(`  ${this.glyphInfos[this.currentIndex].width.toFixed(1)}px`)
              .fontSize(11)
              .fontColor('#10B981')
          }
          .margin({ top: 8, bottom: 12 })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })

单字预览区:Canvas(220px 高)+ 信息行。信息行展示三个数据:

  • 字符本身(绿色高亮)
  • glyph #N:glyph index 编号,灰色
  • Wpx:advance width,绿色

主布局

  build() {
    Scroll() {
      Column() {
        this.headerBar()
        this.inputSection()
        this.pathPreviewCard()
        this.charStripCard()
      }
      .width('100%')
      .padding({ bottom: 24 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0A0A1A')
    .edgeEffect(EdgeEffect.Spring)
    .scrollBar(BarState.Off)
  }

外层 Scroll 包裹整个页面,因为内容可能超出屏幕(尤其是输入较长文本时,字符选择条和路径预览的总高度会超过视口)。edgeEffect(EdgeEffect.Spring) 给滚动添加弹性效果,scrollBar(BarState.Off) 隐藏滚动条。

完整代码

以下是完整代码,可直接复制使用:

import { drawing, common2D } from '@kit.ArkGraphics2D';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { buffer } from '@kit.ArkTS';

const DOMAIN = 0x0000;
const TAG = 'FontPathDemo';

interface GlyphInfo {
  char: string;
  glyphIndex: number;
  width: number;
  bounds: common2D.Rect;
}

@Entry
@Component
struct A4 {
  @State inputText: string = '鸿蒙HarmonyOS';
  @State fontSize: number = 80;
  @State strokeOnly: boolean = false;
  @State glyphInfos: GlyphInfo[] = [];
  @State currentIndex: number = 0;
  @State charCount: number = 0;

  private mainCtx: DrawingRenderingContext = new DrawingRenderingContext();
  private glyphCtx: DrawingRenderingContext = new DrawingRenderingContext();

  aboutToAppear(): void {
    this.charCount = this.inputText.length;
    this.updateGlyphInfos();
  }

  private getByteLength(text: string): number {
    return buffer.from(text).length;
  }

  private drawPathOnCanvas(): void {
    let canvas: drawing.Canvas | undefined = this.mainCtx.canvas;
    if (!canvas) {
      return;
    }
    canvas.clear({ alpha: 0, red: 0, green: 0, blue: 0 });

    let font: drawing.Font = new drawing.Font();
    font.setSize(this.fontSize);
    font.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS);
    font.setHinting(drawing.FontHinting.FULL);

    let path: drawing.Path | undefined = undefined;
    try {
      let byteLen: number = this.getByteLength(this.inputText);
      path = font.getTextPath(this.inputText, byteLen, 0, this.fontSize);
    } catch (e) {
      hilog.error(DOMAIN, TAG, `getTextPath failed`);
      this.mainCtx.invalidate();
      return;
    }

    if (!path) {
      this.mainCtx.invalidate();
      return;
    }

    if (this.strokeOnly) {
      let pen: drawing.Pen = new drawing.Pen();
      pen.setStrokeWidth(2);
      pen.setColor({ alpha: 255, red: 139, green: 92, blue: 246 });
      pen.setAntiAlias(true);
      canvas.attachPen(pen);
      canvas.drawPath(path);
      canvas.detachPen();
    } else {
      let brush: drawing.Brush = new drawing.Brush();
      brush.setColor({ alpha: 255, red: 99, green: 102, blue: 241 });
      brush.setAntiAlias(true);
      canvas.attachBrush(brush);

      let pen: drawing.Pen = new drawing.Pen();
      pen.setStrokeWidth(1.5);
      pen.setColor({ alpha: 255, red: 139, green: 92, blue: 246 });
      pen.setAntiAlias(true);
      canvas.attachPen(pen);

      canvas.drawPath(path);

      canvas.detachBrush();
      canvas.detachPen();
    }

    this.mainCtx.invalidate();
  }

  private drawGlyphOnCanvas(): void {
    let canvas: drawing.Canvas | undefined = this.glyphCtx.canvas;
    if (!canvas) {
      return;
    }
    canvas.clear({ alpha: 0, red: 0, green: 0, blue: 0 });

    if (this.glyphInfos.length === 0 || this.currentIndex >= this.glyphInfos.length) {
      this.glyphCtx.invalidate();
      return;
    }

    let info: GlyphInfo = this.glyphInfos[this.currentIndex];
    let font: drawing.Font = new drawing.Font();
    font.setSize(200);
    font.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS);

    let glyphs: number[] = font.textToGlyphs(info.char);
    if (glyphs.length === 0 || glyphs[0] === 0) {
      this.glyphCtx.invalidate();
      return;
    }

    let glyphPath: drawing.Path | undefined = undefined;
    try {
      glyphPath = font.createPathForGlyph(glyphs[0]);
    } catch (e) {
      hilog.error(DOMAIN, TAG, `createPathForGlyph failed`);
      this.glyphCtx.invalidate();
      return;
    }

    if (!glyphPath) {
      this.glyphCtx.invalidate();
      return;
    }

    let rect: common2D.Rect = glyphPath.getBounds();
    let pathW: number = rect.right - rect.left;
    let pathH: number = rect.bottom - rect.top;
    if (pathW <= 0 || pathH <= 0) {
      this.glyphCtx.invalidate();
      return;
    }

    let canvasW: number = 340;
    let canvasH: number = 220;
    let padding: number = 16;
    let availW: number = canvasW - padding * 2;
    let availH: number = canvasH - padding * 2;
    let scale: number = Math.min(availW / pathW, availH / pathH);
    let offsetX: number = padding + (availW - pathW * scale) / 2 - rect.left * scale;
    let offsetY: number = padding + (availH - pathH * scale) / 2 - rect.top * scale;

    canvas.save();
    canvas.translate(offsetX, offsetY);
    canvas.scale(scale, scale);

    let brush: drawing.Brush = new drawing.Brush();
    brush.setColor({ alpha: 255, red: 16, green: 185, blue: 129 });
    brush.setAntiAlias(true);
    canvas.attachBrush(brush);

    let pen: drawing.Pen = new drawing.Pen();
    pen.setStrokeWidth(2 / scale);
    pen.setColor({ alpha: 255, red: 5, green: 150, blue: 105 });
    pen.setAntiAlias(true);
    canvas.attachPen(pen);

    canvas.drawPath(glyphPath);

    canvas.detachBrush();
    canvas.detachPen();
    canvas.restore();
    this.glyphCtx.invalidate();
  }

  private updateGlyphInfos(): void {
    try {
      let font: drawing.Font = new drawing.Font();
      font.setSize(50);
      let glyphs: number[] = font.textToGlyphs(this.inputText);
      let widths: number[] = font.getWidths(glyphs);
      let bounds: Array<common2D.Rect> = font.getBounds(glyphs);

      let infos: GlyphInfo[] = [];
      for (let i = 0; i < glyphs.length; i++) {
        let charStr: string = i < this.inputText.length ? this.inputText[i] : '?';
        infos.push({
          char: charStr,
          glyphIndex: glyphs[i],
          width: widths[i],
          bounds: bounds[i],
        });
      }
      this.glyphInfos = infos;
      this.charCount = infos.length;
      if (this.currentIndex >= infos.length) {
        this.currentIndex = infos.length > 0 ? infos.length - 1 : 0;
      }
    } catch (e) {
      hilog.error(DOMAIN, TAG, `updateGlyphInfos failed`);
      this.glyphInfos = [];
      this.charCount = 0;
    }
  }

  private prevChar(): void {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      this.drawGlyphOnCanvas();
    }
  }

  private nextChar(): void {
    if (this.currentIndex < this.glyphInfos.length - 1) {
      this.currentIndex++;
      this.drawGlyphOnCanvas();
    }
  }

  @Builder
  headerBar() {
    Row() {
      Text('字体轮廓路径')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Text(`${this.glyphInfos.length} 字形`)
        .fontSize(12)
        .fontColor('#6366F1')
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .alignItems(VerticalAlign.Center)
    .backgroundColor('#0F0F23')
  }

  @Builder
  inputSection() {
    Column() {
      TextInput({ placeholder: '输入文本获取轮廓路径...', text: this.inputText })
        .width('100%')
        .height(44)
        .fontSize(15)
        .fontColor('#FFFFFF')
        .placeholderColor('#555577')
        .backgroundColor('#1A1A33')
        .borderRadius(22)
        .padding({ left: 20, right: 20 })
        .caretColor('#6366F1')
        .onChange((value: string) => {
          this.inputText = value;
          this.currentIndex = 0;
          this.drawPathOnCanvas();
          this.updateGlyphInfos();
          this.drawGlyphOnCanvas();
        })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16, bottom: 12 })
    .backgroundColor('#141428')
  }

  @Builder
  pathPreviewCard() {
    Column() {
      Row() {
        Text('文字轮廓路径')
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
        Blank()
        Text(this.strokeOnly ? '描边模式' : '填充+描边')
          .fontSize(11)
          .fontColor('#6366F1')
          .onClick(() => {
            this.strokeOnly = !this.strokeOnly;
            this.drawPathOnCanvas();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 4 })

      Canvas(this.mainCtx)
        .width('100%')
        .height(120)
        .backgroundColor('#0A0A1A')
        .borderRadius(12)
        .onReady(() => {
          this.drawPathOnCanvas();
        })

      Row() {
        Text('字号')
          .fontSize(12)
          .fontColor('#AAAACC')
          .width(36)
        Slider({ value: this.fontSize, min: 20, max: 200, step: 10 })
          .layoutWeight(1)
          .trackColor('#1A1A33')
          .selectedColor('#6366F1')
          .blockColor('#FFFFFF')
          .onChange((value: number) => {
            this.fontSize = value;
            this.drawPathOnCanvas();
          })
        Text(`${this.fontSize}px`)
          .fontSize(11)
          .fontColor('#6366F1')
          .width(48)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 12 })
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .backgroundColor('#0F0F23')
    .borderRadius(16)
    .margin({ left: 16, right: 16, top: 8 })
    .shadow({ radius: 8, color: '#00000033', offsetY: 2 })
  }

  @Builder
  charStripCard() {
    Column() {
      Text('逐字路径 (左右滑动切换)')
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .fontColor('#FFFFFF')
        .width('100%')
        .padding({ left: 16, top: 12, bottom: 4 })

      if (this.glyphInfos.length > 0) {
        Row() {
          Row() {
            Text('<')
              .fontSize(18)
              .fontColor(this.currentIndex > 0 ? '#6366F1' : '#333355')
              .fontWeight(FontWeight.Bold)
          }
          .width(36)
          .height(36)
          .justifyContent(FlexAlign.Center)
          .alignItems(VerticalAlign.Center)
          .backgroundColor('#1A1A33')
          .borderRadius(18)
          .onClick(() => this.prevChar())

          Scroll() {
            Row() {
              ForEach(this.glyphInfos, (info: GlyphInfo, index: number) => {
                Column() {
                  Text(info.char)
                    .fontSize(18)
                    .fontColor(index === this.currentIndex ? '#FFFFFF' : '#888899')
                    .fontWeight(index === this.currentIndex ? FontWeight.Bold : FontWeight.Normal)
                }
                .width(36)
                .height(36)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(index === this.currentIndex ? '#6366F1' : '#1A1A33')
                .borderRadius(8)
                .margin({ left: 4, right: 4 })
                .onClick(() => {
                  this.currentIndex = index;
                  this.drawGlyphOnCanvas();
                })
              }, (_info: GlyphInfo, index: number) => `${index}`)
            }
          }
          .layoutWeight(1)
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)

          Row() {
            Text('>')
              .fontSize(18)
              .fontColor(this.currentIndex < this.glyphInfos.length - 1 ? '#6366F1' : '#333355')
              .fontWeight(FontWeight.Bold)
          }
          .width(36)
          .height(36)
          .justifyContent(FlexAlign.Center)
          .alignItems(VerticalAlign.Center)
          .backgroundColor('#1A1A33')
          .borderRadius(18)
          .onClick(() => this.nextChar())
        }
        .width('100%')
        .padding({ left: 12, right: 12, bottom: 8 })
        .alignItems(VerticalAlign.Center)

        Column() {
          Canvas(this.glyphCtx)
            .width('100%')
            .height(220)
            .backgroundColor('#0A0A1A')
            .borderRadius(12)
            .onReady(() => {
              this.drawGlyphOnCanvas();
            })

          Row() {
            Text(this.glyphInfos[this.currentIndex].char)
              .fontSize(16)
              .fontColor('#10B981')
              .fontWeight(FontWeight.Bold)
            Text(`  glyph #${this.glyphInfos[this.currentIndex].glyphIndex}`)
              .fontSize(11)
              .fontColor('#888899')
            Text(`  ${this.glyphInfos[this.currentIndex].width.toFixed(1)}px`)
              .fontSize(11)
              .fontColor('#10B981')
          }
          .margin({ top: 8, bottom: 12 })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
      } else {
        Text('输入文本查看逐字路径')
          .fontSize(12)
          .fontColor('#666688')
          .padding({ left: 16, top: 8, bottom: 12 })
      }
    }
    .width('100%')
    .backgroundColor('#0F0F23')
    .borderRadius(16)
    .margin({ left: 16, right: 16, top: 8 })
    .shadow({ radius: 8, color: '#00000033', offsetY: 2 })
  }

  build() {
    Scroll() {
      Column() {
        this.headerBar()
        this.inputSection()
        this.pathPreviewCard()
        this.charStripCard()
      }
      .width('100%')
      .padding({ bottom: 24 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0A0A1A')
    .edgeEffect(EdgeEffect.Spring)
    .scrollBar(BarState.Off)
  }
}

总结

这个字体轮廓路径提取案例的核心可以归纳为三条主线:

1. drawing.Font 的三条路径提取 API 及其适用场景。 getTextPath 适合整体文字路径渲染——一次调用拿到合并路径,直接 drawPath 即可;textToGlyphs + createPathForGlyph 适合逐字拆解——先转 glyph index 再逐个提取路径,额外获得 getWidthsgetBounds 的细粒度信息。两种模式的关键区别:getTextPath 的路径坐标包含文本布局信息(基线位置、字间距),createPathForGlyph 的路径坐标是字形本地坐标系(原点在基线左端),需要手动变换才能正确显示。

2. getTextPath 的 byteLength 参数陷阱。 这个参数要求 UTF-8 字节长度,而 JavaScript 的 string.length 返回 UTF-16 码元数。中文文本中两者差异显著(每个汉字 UTF-8 占 3 字节 vs UTF-16 占 1 码元),传错会导致路径生成失败。必须用 buffer.from(text).length 正确计算。这个坑在纯英文文本中不会暴露(ASCII 字符两种长度一致),但一旦涉及中文就会踩到。

3. 单字路径的自动居中缩放。 createPathForGlyph 返回的路径在不同字符间 bounding box 差异极大,不能直接渲染。正确的做法是:getBounds() 获取实际边界 → 按画布可用空间计算等比缩放 → 补偿路径原点偏移(- rect.left * scale)→ canvas.save/translate/scale/drawPath/restore。一个容易忽略的细节:Pen 的线宽会被 canvas.scale 放大,需要 setStrokeWidth(2 / scale) 修正。

后续可以在当前结构上扩展:用 Pathop 方法做路径布尔运算(文字路径与几何图形的交集/并集)、用 PathMeasure 沿文字轮廓做粒子动画、加载自定义 TTF/OTF 字体替换默认字体、导出 Path 数据为 SVG。无论扩展哪一项,建议保留本文的两个关键约束:byteLength 必须精确计算,单字路径必须通过 getBounds + 动态缩放居中显示。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐