在将手账日记、灵感小诗渲染到相纸卡片或导出为高清分享图时版面文字的排版美感直接决定了整张手账的视觉格调。在传统的 DOM 网页排版中现代浏览器通过 CSS 的text-align: justify、line-break: strict以及排版禁则规则为我们提供了很多现成的封装。然而一旦我们脱离 DOM 树进入纯粹由代码绘制的HTML5 Canvas、离屏渲染管线或自研排版引擎时文字的折行与度量就必须由开发者亲手一行一行计算。很多刚接触 Canvas 文字渲染的同学往往采用最简单的“贪心测量法”从左到右累加字符宽度一旦超过单行最大宽度就强行换行。这种粗暴的处理方式在中文排版中会暴露出极其刺眼的排版缺陷行首出现禁则标点违反避头法则逗号、句号、感叹号、右引号竟然被孤零零地甩到了下一行的开头行尾出现前置标点违反避尾法则左括号、书名号的前半部分出现在行末与它的内容被硬生生撕裂在两行孤字成行Orphan Line一段长文的最后一行仅有可怜兮兮的一个单字加句号破坏了版面的矩形韵律与沉稳感。为了让手账导出的每一幅画面都具备如同高级图书与手账杂志般的印刷级美感听汐在排版工坊中设计了一套纯前端 Canvas 中文避头尾智能折行引擎。本文将拆解其背后的禁则算法与动态字距微调实现。一、中文排版三大禁则规范Kinsoku Shori在汉字活字排版历史与现代排版标准中文字与标点的搭配有严格的审美约定避头标点不能出现在一行开头句末与停顿标点、。、、、、、、结束括号与引号、】、》、”、’、、附着标点…、—避尾标点不能出现在一行末尾起始括号与引号、【、《、“、‘、〔前置修饰符货币符号¥等字距微调两端对齐Justified Kerning除段落末尾最后一行外每行文字的两端应当严格与版心边界对齐。当某一行因为避头避尾规则提前折行时行内各汉字之间的空白间距应该以亚像素级别平摊而不是任由右侧留下一截丑陋的空缺。二、折行测量管线的架构设计排版管线分为三个精密阶段[原始文本输入 字符宽度缓存表构建] │ ▼ [阶段一基于避头尾禁则的前瞻折行切分 (Lookahead Line-Breaking)] │ ▼ [阶段二末行孤字消解机制 (Orphan Word Elimination)] │ ▼ [阶段三逐行两端对齐与字距动态平摊绘制 (Justified Drawing)]为了避免在渲染长文本时反复调用昂贵的ctx.measureText()我们在切分前对涉及到的字形做一次哈希记忆化缓存保证算法在毫秒内完成全版面编排。三、纯前端排版引擎核心工程代码下面是完整的 TypeScript 类实现不依赖任何第三方排版库纯原生 Canvas API 支持// types.ts export interface TypesetOptions { fontSize: number; fontFamily: string; maxWidth: number; lineHeight: number; color?: string; justify?: boolean; // 是否启用两端对齐 } export interface FormattedLine { text: string; isParagraphEnd: boolean; charSpacing: number; // 两端对齐时每个字额外补充的间距 } // AutumnTypesetter.ts export class AutumnTypesetter { // 避头标点集合 private static noStartChars new Set([ , 。, , , , , 、, , 】, 》, ”, ’, …, —, ., ,, !, ?, ;, :, ), ], } ]); // 避尾标点集合 private static noEndChars new Set([ , 【, 《, “, ‘, (, [, {, ¥, $ ]); /** * 将长文本切分为严格符合排版禁则的行数据 */ public static layoutText( ctx: CanvasRenderingContext2D, text: string, options: TypesetOptions ): FormattedLine[] { ctx.font ${options.fontSize}px ${options.fontFamily}; const { maxWidth, justify true } options; const paragraphs text.split(\n); const resultLines: FormattedLine[] []; for (const para of paragraphs) { if (para.trim().length 0) { // 空行占位 resultLines.push({ text: , isParagraphEnd: true, charSpacing: 0 }); continue; } const chars Array.from(para); let startIndex 0; while (startIndex chars.length) { let count 0; let currentWidth 0; // 1. 贪心前向探测最大可容纳字符 while (startIndex count chars.length) { const char chars[startIndex count]; const w ctx.measureText(char).width; if (currentWidth w maxWidth) { break; } currentWidth w; count; } // 如果整段剩余文字已经能被这一行容纳完毕 if (startIndex count chars.length) { resultLines.push({ text: chars.slice(startIndex).join(), isParagraphEnd: true, charSpacing: 0 }); break; } // 2. 核心避头法则防御与回溯重分配 const nextChar chars[startIndex count]; if (this.noStartChars.has(nextChar) count 1) { // 下一行开头撞上了避头标点当前行必须退让一个字符带到下一行 count--; } // 3. 核心避尾法则防御 const curLastChar chars[startIndex count - 1]; if (this.noEndChars.has(curLastChar) count 1) { // 当前行末尾是避尾标点将其推移到下一行行首 count--; } // 4. 提取当前行字符串并计算两端对齐微调间距 const lineStr chars.slice(startIndex, startIndex count).join(); const actualWidth ctx.measureText(lineStr).width; let charSpacing 0; // 只有非段末行且字数大于 1 时才进行两端间距平摊 if (justify count 1) { const gapCount count - 1; const remainingSpace maxWidth - actualWidth; // 当空缺间隙在合理容差内不超过半个汉字才做对齐拉伸避免字间距过宽 if (remainingSpace 0 remainingSpace options.fontSize * 0.6 * gapCount) { charSpacing remainingSpace / gapCount; } } resultLines.push({ text: lineStr, isParagraphEnd: false, charSpacing }); startIndex count; } // 5. 孤字成行防御若末尾只有 1 个字符从上一行借一个字过来 if (resultLines.length 2) { const last resultLines[resultLines.length - 1]; const prev resultLines[resultLines.length - 2]; if (last.text.length 1 !prev.isParagraphEnd prev.text.length 3) { const borrowedChar prev.text[prev.text.length - 1]; prev.text prev.text.slice(0, -1); last.text borrowedChar last.text; // 重新计算上一行的对齐间距 const newPrevWidth ctx.measureText(prev.text).width; prev.charSpacing (maxWidth - newPrevWidth) / (prev.text.length - 1); } } } return resultLines; } /** * 将排版结果精确绘制到指定 Canvas 坐标上 */ public static renderToCanvas( ctx: CanvasRenderingContext2D, lines: FormattedLine[], startX: number, startY: number, options: TypesetOptions ): number { ctx.save(); ctx.font ${options.fontSize}px ${options.fontFamily}; ctx.fillStyle options.color || #332722; ctx.textBaseline top; let currentY startY; for (const line of lines) { if (line.text.length 0) { currentY options.lineHeight * 0.6; continue; } if (line.charSpacing 0) { // 普通直接绘制 ctx.fillText(line.text, startX, currentY); } else { // 逐字平摊间距绘制达成精美两端对齐 let charX startX; for (const ch of Array.from(line.text)) { ctx.fillText(ch, charX, currentY); const chWidth ctx.measureText(ch).width; charX chWidth line.charSpacing; } } currentY options.lineHeight; } ctx.restore(); return currentY; // 返回最终排版占用的底部 Y 坐标 } }四、排版效果对比与手账相框实操在排版一段真实的秋日感悟时对比效果立竿见影未优化前第二行行首出现第三行末尾悬挂着《最后一行单独空出一颗孤零零的。右侧边缘参差不齐宛如犬牙交错视觉极不舒适。启用本算法后所有标点均妥善依附在文字身旁两端如刀切般规整方正字距温润舒展整段文字仿佛是用活字铅块精工排版印在米黄水彩纸上。配合手账相纸的背景阴影与微微的旋转角导出的手账便签瞬间充满了高阶设计师手作的质感。五、结语很多人以为前端做排版只要把文字fillText扔上去就算完事但真正的质感往往藏在那 1 像素的字距平摊和标点避让的克制之中。在纯前端代码中重构中文活字印刷的禁则之美是用现代数字技术向传统汉字印刷美学最虔诚的致敬。每一行方正自洽的文字都在为写下它的生活悄悄加冕。