☰
网页图片编辑器如何添加文字:字体、换行与导出一致性
2026/9/28 21:12:18 网站建设 项目流程

在网页图片编辑器中,添加文字看似只是调用 fillText,真正容易出问题的是字体还没加载就开始测量、预览和导出使用了两套换行规则,以及选择框尺寸没有跟着多行文字更新。本文结合图片猫 www.piccat.cn 前端的文字编辑器实现,说明怎样把文字对象、字体加载、测量换行和最终导出串成一条可验证的渲染链。

文中的项目代码摘录来自当前仓库;教学简化片段只保留核心逻辑;建议方案用于说明当前实现仍可加强的部分。

一 先把文字当成对象,而不是一串像素

图片猫的 ImageTextEditor 将文字作为 DesignObject 保存,内容、位置、宽高、字号、字体栈、颜色、描边、粗体、斜体、旋转、字距、对齐方式和行高都在同一个对象里。这样,撤销、工程 JSON 和导出可以复用同一份数据。

type DesignObject = {
id: number;
type: 'text' | 'symbol' | 'shape' | 'image';
text: string;
x: number; y: number; width: number; height: number;
fontSize: number; fontFamily: string;
fill: string; stroke: string; strokeWidth: number;
bold: boolean; italic: boolean; rotation: number;
letterSpacing: number; align: 'left' | 'center' | 'right';
lineHeight: number;
};

这是项目代码的关键字段摘录,省略了图片、形状和背景卡片字段。width 不只是界面选框宽度,它还参与换行和包围盒测量;lineHeight 与 letterSpacing 也必须进入对象模型。

二 字体问题的根源:fontFamily 不等于字体已经可用

Canvas 的 ctx.font 只接收字体描述字符串,并不会替你下载或安装字体。用户选择的系统字体可能不存在,导入字体需要通过 FontFace 加载,字体库字体还可能需要先下载并交给浏览器缓存。如果在字体就绪前调用 measureText,宽度可能来自回退字体;等目标字体加载完成后,同样的字符串就可能换行到不同位置。

项目代码摘录:选择字体时,loadFontOption 构造 FontFace,等待 fontFace.load() 后加入 document.fonts,并等待 document.fonts.ready;selectFont 应用到对象后再次等待字体就绪再渲染。

const fontFace = new FontFace(font.fontFaceName, 'url(' + fontUrl + ')');
const loadedFace = await fontFace.load();
document.fonts.add(loadedFace);
await document.fonts.ready;
loadedFontValues.add(font.value);

// 选择字体后,等字体就绪再渲染
await document.fonts.ready;
renderAndSave();

项目还用同一段样本文字比较目标字体栈和 serif、sans-serif、monospace 的 measureText 宽度,作为系统字体可用性的启发式检测。它不能证明所有字形都覆盖,但比只看字体名称更可靠。

三 换行要以 Canvas 测量为准

CSS 的 word-break 或 DOM 的 scrollWidth 不能直接替代 Canvas 导出时的测量。getWrappedTextLines 先尊重文本中的换行符,再用 Array.from 逐字符累积,调用 measureLine 判断是否超过对象宽度。

const getWrappedTextLines = (ctx, object) => {
const maxWidth = Math.max(40, object.width || canvasWidth.value);
const result = [];
object.text.split('\\n').forEach((paragraph) => {
if (!paragraph) { result.push(''); return; }
let line = '';
for (const char of Array.from(paragraph)) {
const nextLine = line + char;
if (line && measureLine(ctx, nextLine, object.letterSpacing) > maxWidth) {
result.push(line);
line = char;
} else {
line = nextLine;
}
}
result.push(line);
});
return result.length ? result : [''];
};

const measureLine = (ctx, text, letterSpacing) => {
if (!letterSpacing) return ctx.measureText(text).width;
return Array.from(text).reduce(
(sum, char, index) => sum + ctx.measureText(char).width + (index ? letterSpacing : 0),
0
);
};

代码只在 line 已经有内容时换行,因此单个超宽字符不会被拆开。对中文标题通常合理;如果要处理超宽英文单词或连续 URL,应增加单词级和字符级策略,并显示溢出提示。复杂 emoji 和组合字符也需要专项验收。

四 绘制、选框与导出必须复用同一套测量

绘制时项目先设置 ctx.font 和 textBaseline,再调用 getWrappedTextLines。行高由 fontSize * lineHeight 计算,contentWidth 取所有行的最大测量宽度,随后绘制背景、阴影和文字。选框测量函数也使用相同换行与字距逻辑,并把描边和背景内边距计入包围盒。

ctx.font = (object.italic ? 'italic ' : '')
+ (object.bold ? '800 ' : '500 ')
+ object.fontSize + 'px ' + object.fontFamily;
ctx.textBaseline = 'middle';
const lines = getWrappedTextLines(ctx, object);
const lineHeight = object.fontSize * object.lineHeight;
const contentWidth = Math.max(
...lines.map((line) => measureLine(ctx, line, object.letterSpacing)),
Math.min(object.width, object.fontSize)
);
const contentHeight = Math.max(object.fontSize, lines.length * lineHeight);
const startY = -((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
drawTextLine(ctx, line, getAlignedLineX(contentWidth, object), startY + index * lineHeight, object);
});

drawTextLine 在没有字距时直接调用 fillText 或 strokeText;设置字距时逐字符绘制,并把每个字符的 measureText 宽度与 letterSpacing 相加。这种实现清晰可控,但字距非零时,复杂连字可能无法保持浏览器默认的字形合并效果。

五 导出前再走一次字体准备和无选择渲染

导出不能直接截图当前预览 Canvas,因为预览可能包含选中框、吸附线或尚未完成的字体。项目的 downloadResult 先调用 ensureFontsForObjects,创建新 Canvas,用 renderCanvas(canvas, false) 绘制不含选择状态的结果,JPEG 再通过 flattenCanvas 填充白底,最后使用 toBlob 生成文件。

await ensureFontsForObjects();
const canvas = document.createElement('canvas');
renderCanvas(canvas, false); // false:不绘制选中框和辅助线
const exportCanvas = exportFormat.value === 'image/jpeg'
? flattenCanvas(canvas)
: canvas;
const blob = await new Promise((resolve) =>
exportCanvas.toBlob(resolve, exportFormat.value, quality)
);
if (!blob) throw new Error('图片导出失败');
await downloadBlob(blob, fileName);

这是项目代码的教学化摘录,下载统计和错误提示被省略。关键顺序是:准备字体,创建独立导出画布,关闭选择状态,再导出 Blob。导出成功也不等于不同设备上的字体完全一致。

六 当前实现与可以继续完善的地方

状态

当前代码依据

边界或建议

已经实现

文字对象保存字体、字号、宽度、行高和字距;预览与导出共用对象绘制。

继续关注不同浏览器的字体渲染差异。

已经实现

系统字体检测、FontFace 导入、字体库加载、document.fonts.ready。

覆盖下载失败和取消路径。

已经实现

换行按显式换行和 measureText 逐字符计算;导出前等待字体。

复杂 emoji、组合字符和超宽单词需专项规则。

建议方案

按字体栈、字号、字距、文本和宽度缓存测量结果。

字体或文本变化时必须使缓存失效。

建议方案

工程 JSON 保存字体来源元数据,并提示目标设备缺失字体。

不能只保存显示名称。

七 建议验收清单与已完成核对

已完成的源码核对包括:DesignObject 包含字体、宽度、行高和字距;getWrappedTextLines 使用显式换行和 measureText;导出前调用 ensureFontsForObjects,并用 renderCanvas(canvas, false) 绘制无选择状态;toBlob 负责最终 Blob。这些是源码核对,不等同于完整跨浏览器测试。

  • 选择本机不存在的系统字体,确认提示导入或回退。
  • 导入 TTF、OTF、WOFF、WOFF2,覆盖成功、失败和取消。
  • 输入中文、英文、连续 URL、空行、emoji 和超长单词,比较预览与导出换行。
  • 修改字号、字距、行高、描边和背景内边距,确认选框、预览和导出同步。
  • 字体下载尚未完成时导出,确认等待或明确失败,不生成半成品。
  • 用 PNG、JPEG、WebP 检查透明背景和 JPEG 白底处理。

八 结语

网页图片编辑器的文字功能,核心不是把输入框内容画到 Canvas,而是让“文字对象 → 字体就绪 → 宽度测量 → 换行 → 绘制 → 导出”保持同一套规则。图片猫当前实现已经把这些步骤集中在 ImageTextEditor 中:字体通过 FontFace 和 document.fonts 管理,换行通过 measureText 计算,预览和导出共享对象绘制逻辑。剩余风险主要来自字体覆盖、复杂 Unicode 和不同浏览器的字形差异,需要用验收用例验证。

参考资料:

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText

https://developer.mozilla.org/en-US/docs/Web/API/FontFace

https://developer.mozilla.org/en-US/docs/Web/API/Document/fonts

https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询