【共创稿事节】HarmonyOS7 字体轮廓路径提取实战:ArkGraphics2D 把文字变成 Path
文章目录
前言
如果要对文字做描边、路径动画或沿轮廓绘制图形,首先需要拿到字形的矢量轮廓。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.Font、drawing.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模块提供Font、Path、Canvas、Pen、Brush等矢量绘图类型;common2D提供Rect等基础 2D 数据结构@kit.ArkTS:buffer模块用于计算文本的字节长度——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 indexwidth:字符的水平前进宽度(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 字节长度。
为什么这很重要?举几个例子:
| 字符串 | .length | UTF-8 字节长度 | 说明 |
|---|---|---|---|
"ABC" | 3 | 3 | 纯 ASCII,两者一致 |
"鸿蒙" | 2 | 6 | 每个汉字 UTF-8 占 3 字节 |
"HarmonyOS" | 9 | 9 | 纯 ASCII |
"鸿蒙HarmonyOS" | 12 | 15 | 6 + 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 字节长度(前文已详述)x、y:文本基线的起始坐标。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;
}
两步提取单字路径:
font.textToGlyphs(info.char):将单个字符转为 glyph index 数组。一个字符通常对应一个 glyph,但合字(ligature)可能一对多。glyphs[0] === 0表示该字符在当前字体中没有对应的 glyph(字体不包含该字符),此时跳过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 的坐标空间和画布坐标空间不一致,需要变换使其居中显示。
逐步拆解:
-
glyphPath.getBounds():获取路径的轴对齐边界矩形。rect.left/top是路径最左/最上方的坐标,rect.right/bottom是最右/最下方。注意rect.left可能是负数(比如字符向左延伸超出原点),rect.top也可能是负数(比如字符有上伸部如"b"的竖线顶部高于基线) -
计算缩放比:
scale = Math.min(availW / pathW, availH / pathH)。取宽高缩放比的较小值,保证字符完整显示在画布内(等比缩放,不变形) -
计算偏移量:分两部分理解——
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 的调用顺序:
font.textToGlyphs(text):输入文本字符串,输出 glyph index 数组。返回数组的长度通常等于文本的字符数,但合字和 emoji 可能导致长度不同font.getWidths(glyphs):输入 glyph index 数组,输出每个 glyph 的 advance width 数组。这个宽度是字号相关的——字号 50px 下的宽度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.length 和 inputText.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 })
}
路径预览卡片分三层:
- 标题行:左侧"文字轮廓路径",右侧渲染模式标签(可点击切换)
- Canvas:使用
DrawingRenderingContext的 Canvas 组件,高度 120。onReady回调在 Canvas 初始化完成后触发,首次绘制 - 字号控制: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 再逐个提取路径,额外获得 getWidths 和 getBounds 的细粒度信息。两种模式的关键区别: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) 修正。
后续可以在当前结构上扩展:用 Path 的 op 方法做路径布尔运算(文字路径与几何图形的交集/并集)、用 PathMeasure 沿文字轮廓做粒子动画、加载自定义 TTF/OTF 字体替换默认字体、导出 Path 数据为 SVG。无论扩展哪一项,建议保留本文的两个关键约束:byteLength 必须精确计算,单字路径必须通过 getBounds + 动态缩放居中显示。
更多推荐


所有评论(0)