【Canvas 2D】day43-text-layout

作者:mario 发布时间: 2026-09-02 阅读量:6 评论数:0

Canvas 文本渲染进阶 — 自动换行、省略号、多行排版,写出排版引擎

Day 33 学的 fillText 是"一行流":给坐标、画一行。但真实产品里的文本全是"排版问题":节点标签要在固定宽度里自动换行,太长要截断加省略号,多行要控制行高,仪表盘可能还要竖排。更要命的是 measureText 有真实开销——拖拽节点时每帧排版几十个标签,不缓存测量结果必卡。今天写一个完整的文字排版引擎 drawWrappedText,它将直接进入第 49 天编辑器的节点渲染,也是你未来一切"画布内富文本"的地基。


目录


一、为什么 Canvas 文本这么"原始"

1.1 浏览器里有现成的文字排版,但 Canvas 不用

// HTML/CSS 世界:浏览器自动排版
<div style="width: 120px;">一段很长的设备描述文字</div>
// 自动换行 ✅ 溢出处理(ellipsis) ✅ 行高 ✅ —— 全免费

// Canvas 世界:只有"把字符串画在锚点上"的原语
ctx.fillText("一段很长的设备描述文字", 0, 0);
// 超出宽度?照样画出去,被裁掉都不知道 ❌
// 因为 Canvas 是"立即模式"(Immediate Mode):
// 它只执行绘图指令,不维护 DOM 那样的文档结构 → 没法自动排版

1.2 排版引擎要解决的四件事

① 测量:这段文字画出来多宽?(measureText)
② 断行:在哪里断才能既不超宽又尽量填满?(换行算法)
③ 截断:放不下时砍哪里、怎么加省略号?
④ 垂直:多行之间隔多少(行高)、整体在容器里怎么对齐?

二、measureText:唯一的测量工具

2.1 基本用法

// ⚠ 先设 font,再测量(测量结果依赖当前字体!)
ctx.font = "14px sans-serif";
const m = ctx.measureText("设备温度过高警告");

m.width;          // 文字宽度(像素)—— 90% 场景只用它
m.actualBoundingBoxAscent;   // 锚点之上的实际高度(字形真实边界,不含行距)
m.actualBoundingBoxDescent;  // 锚点之下的实际高度
m.fontBoundingBoxAscent;     // 字体设计上的高度(含行距预留)
m.fontBoundingBoxDescent;

2.2 ascent/descent:精准垂直居中的钥匙

/**
 * 让文字在高度 h 的容器里垂直居中
 *
 * 错误做法(新手 99% 这么写):
 *   y = 容器顶 + h/2,textBaseline = "alphabetic"(默认基线)
 *   → 文字偏下(基线在"字形的底部附近",不是中心)
 *
 * 正确做法 A:textBaseline = "middle"(够用,但不同字体有细微偏差)
 * 正确做法 B:用 actualBoundingBox 精确补偿(像素级居中)
 */
function drawCentered(ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number): void {
  const m = ctx.measureText(text);
  // 字形真实顶 = 基线 - ascent;真实中心 = 基线 - (ascent - descent)/2
  const baseline = cy + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
  ctx.textAlign = "center";     // 水平居中交给 textAlign
  ctx.fillText(text, cx, baseline);
}

2.3 测量的性能真相

// measureText 不是纯 JS 计算——要走字体光栅化管线,开销不小:
// 单次 ≈ 0.005~0.02ms,看着小,但是:

// ❌ 反面模式:拖拽节点时每帧、每个节点、每个字符重新测量
for (const node of nodes) {          // 50 个节点
  for (const ch of node.label) {     // 平均 10 字符
    ctx.measureText(ch);             // 500 次/帧 → 5~10ms 全喂给测量了!
  }
}

// ✅ 正解:缓存(第六节展开)

三、自动换行算法

3.1 问题定义

输入:文本 "水泵电机温度超过阈值请检修"(15 字)
约束:容器宽 90px,字体 14px(一个汉字 ≈ 14px 宽)
输出:分行的数组:
  ["水泵电机温度超",   ← 7 字 × 14 = 98px?超了 → 6 字
   "过阈值请检修"]

3.2 逐字断行(中文场景)

中文没有空格分词,最小断行单位就是单字——算法反而简单:

/**
 * 中文逐字换行:贪心策略——每行尽量塞,塞不下就断
 * @returns 分行后的字符串数组
 */
function wrapCJK(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
  const lines: string[] = [];
  let current = "";    // 当前正在组装的行

  for (const ch of text) {
    const candidate = current + ch;
    if (ctx.measureText(candidate).width <= maxWidth) {
      current = candidate;            // 还塞得下 → 继续攒
    } else {
      lines.push(current);            // 塞不下了 → 当前行入队
      current = ch;                   // 新行从当前字符开始
    }
  }
  if (current) lines.push(current);   // 别忘了最后一行
  return lines;
}

3.3 逐词断行(英文场景)

英文单词不能从中间劈开(“pump” 拆成 “pu/mp” 没法读)——最小单位换成

/**
 * 英文按空格换行:贪心 + 单词不拆分
 * 超长单词(比如 URL)单独占一行硬拆(兜底)
 */
function wrapLatin(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
  const lines: string[] = [];
  let current = "";

  for (const word of text.split(/\s+/)) {
    // 情况 1:单词本身就超宽 → 硬拆字符(兜底,如超长 URL)
    if (ctx.measureText(word).width > maxWidth) {
      if (current) { lines.push(current); current = ""; }
      lines.push(...hardBreakWord(ctx, word, maxWidth));
      continue;
    }
    // 情况 2:常规——试放"当前行 + 空格 + 该词"
    const candidate = current ? `${current} ${word}` : word;
    if (ctx.measureText(candidate).width <= maxWidth) {
      current = candidate;
    } else {
      lines.push(current);
      current = word;
    }
  }
  if (current) lines.push(current);
  return lines;
}

/** 超长单词的硬拆(逐字符贪心) */
function hardBreakWord(ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] {
  const parts: string[] = [];
  let cur = "";
  for (const ch of word) {
    if (ctx.measureText(cur + ch).width <= maxWidth) cur += ch;
    else { parts.push(cur); cur = ch; }
  }
  if (cur) parts.push(cur);
  return parts;
}

3.4 中英混排:逐字 + 空格不拆

/**
 * 混排策略(工程上最实用的简化):
 * 按字符遍历,但"遇到空格后的连续拉丁串"视为不可拆单元
 * (更精细的 Unicode 断行算法属于 ICU 库的领域,工业场景这个简化足够)
 */
function* tokens(text: string): Generator<string> {
  let buf = "";
  for (const ch of text) {
    if (/[A-Za-z0-9]/.test(ch)) {
      buf += ch;                       // 拉丁字母/数字:攒进 buffer(不拆)
    } else {
      if (buf) { yield buf; buf = ""; }
      yield ch;                        // 中文/标点:单字 yield
    }
  }
  if (buf) yield buf;
}
// 然后把 3.2 的 for (const ch of text) 换成 for (const tk of tokens(text)) 即可

四、省略号与截断

4.1 单行省略号(CSS ellipsis 的 Canvas 版)

/**
 * 单行截断:超宽时砍尾 + "..."
 * 难点:"..." 自己也占宽度!砍的时候要给它留位置
 */
function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
  if (ctx.measureText(text).width <= maxWidth) return text;   // 不超宽,原样返回

  const ELLIPSIS = "…";                 // 用单字符省略号(比 "..." 窄)
  const ellW = ctx.measureText(ELLIPSIS).width;
  const limit = maxWidth - ellW;        // 文字本体只能用这么多

  // 二分查找"最多能保留几个字"(比逐字删快:log n 次测量)
  let lo = 0, hi = text.length;
  while (lo < hi) {
    const mid = Math.ceil((lo + hi) / 2);
    if (ctx.measureText(text.slice(0, mid)).width <= limit) lo = mid;
    else hi = mid - 1;
  }
  return text.slice(0, lo) + ELLIPSIS;
}

4.2 多行省略号(N 行后截断)

/**
 * 多行截断:先换行,超过 maxLines 的部分砍掉,末行加省略号
 * 典型场景:节点标签最多 2 行
 */
function wrapWithEllipsis(ctx: CanvasRenderingContext2D, text: string, maxWidth: number, maxLines: number): string[] {
  const lines = wrapCJK(ctx, text, maxWidth);   // 第三节的换行
  if (lines.length <= maxLines) return lines;

  const kept = lines.slice(0, maxLines);        // 留前 N 行
  kept[maxLines - 1] = ellipsize(ctx, kept[maxLines - 1], maxWidth);   // 末行再加省略号
  // ⚠ 末行本来是满的,加 "…" 可能又超宽 → ellipsize 会再砍一次,最终一定 ≤ maxWidth ✅
  return kept;
}

五、多行排版与行高

5.1 行高的行业惯例

// 行高(lineHeight)不是随便定的,CSS 世界的事实标准:
const LINE_HEIGHT_FACTOR = 1.4;   // 14px 字体 → 20px 行高(1.4 倍)

// 太挤(1.0):上下行的字形贴在一起,可读性差
// 太松(2.0):浪费纵向空间,节点变得傻大
// 1.4 ~ 1.6:阅读舒适区(Material Design 用 1.5)

5.2 多行绘制的锚点推进

/**
 * 绘制多行文本:从锚点开始,每行向下推进 lineHeight
 * @param x     左边界(textAlign 建议 "left")
 * @param y     第一行的基线
 */
function drawLines(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number): void {
  ctx.textAlign = "left";
  ctx.textBaseline = "alphabetic";
  lines.forEach((line, i) => {
    ctx.fillText(line, x, y + i * lineHeight);
  });
}

5.3 整块文本的垂直对齐

/**
 * 把 lines 这块文本在 [top, top+boxHeight] 里垂直居中
 * 关键:整块高度 = (行数-1)×lineHeight + 一行的字高
 */
function drawLinesCenteredV(ctx: CanvasRenderingContext2D, lines: string[], x: number, top: number, boxHeight: number, lineHeight: number): void {
  const firstBaseline = top + (boxHeight - (lines.length - 1) * lineHeight) / 2;
  // 再用 actualBoundingBox 微调第一行(第二节的知识),此处从简用 middle:
  ctx.textBaseline = "middle";
  lines.forEach((line, i) => {
    ctx.fillText(line, x, firstBaseline + i * lineHeight);
  });
}

六、性能:测量缓存

6.1 缓存的键与值

/**
 * 排版缓存:同样的 (文字, 字体, 宽度) → 同样的排版结果
 * 键的设计:font + maxWidth + text 三元组
 */
interface CacheEntry {
  lines: string[];       // 分行结果
  totalHeight: number;   // 整块高度(布局要用)
}

const layoutCache = new Map<string, CacheEntry>();

function cachedLayout(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): CacheEntry {
  const key = `${ctx.font}|${maxWidth}|${text}`;
  let entry = layoutCache.get(key);

  if (!entry) {
    const lines = wrapWithEllipsis(ctx, text, maxWidth, 2);
    entry = {
      lines,
      totalHeight: lines.length * 14 * LINE_HEIGHT_FACTOR,   // 14 是字号
    };
    layoutCache.set(key, entry);
  }
  return entry;
}

6.2 缓存淘汰

// Map 只进不出 → 内存无限膨胀(长跑的大屏必炸)
// 简单有效的策略:LRU 近似——超上限清空重来(排版很快,重建成本低)

const MAX_CACHE = 2000;
if (layoutCache.size > MAX_CACHE) layoutCache.clear();

// 更精细的:真 LRU(Map 的插入序特性可做)
// 工业大屏场景节点标签有限且重复率高,"超限清空"足够

6.3 缓存命中率为什么高

节点标签的特点:
- 文字内容:低频变化(用户编辑时才变)
- 字体:几乎不变
- 容器宽度:节点尺寸定了就不变

→ 三元组高度稳定 → 50 个节点的画面,缓存命中率轻松 > 95%
→ 拖拽时的每帧渲染:查缓存(O(1) Map.get)而非重新排版

七、实战:节点标签排版引擎

今天的实战目标:把今天的所有函数组装成一个 drawNodeLabel 模块——固定宽度容器内的自动换行 + 2 行截断 + 垂直居中 + 测量缓存,外加一个演示页面(拖拽节点验证缓存生效时依然流畅)。

// day43-label-engine.ts —— 节点标签排版引擎
import { bootCanvas } from "../day29/canvas-boot.js";

const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);

const FONT = "14px 'Microsoft YaHei', sans-serif";
const LINE_HEIGHT = 14 * 1.4;

// (把第二~六节的 wrapCJK / ellipsize / wrapWithEllipsis / cachedLayout 拷贝进本文件)

/**
 * 节点标签渲染:把 text 排版进 (x, y, w, h) 的盒子里
 * - 水平:居中
 * - 垂直:居中
 * - 超出:2 行 + 省略号
 */
function drawNodeLabel(text: string, x: number, y: number, w: number, h: number): void {
  ctx.font = FONT;

  const layout = cachedLayout(ctx, text, w - 16);   // 左右各留 8px 内边距

  ctx.fillStyle = "#e6f1ff";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";

  const firstMiddle = y + (h - layout.totalHeight) / 2 + LINE_HEIGHT / 2;   // 第一行的"middle 线"
  layout.lines.forEach((line, i) => {
    ctx.fillText(line, x + w / 2, firstMiddle + i * LINE_HEIGHT);
  });
}

// ===== 演示:三个不同长度的标签 + 拖拽验证性能 =====
interface Node { x: number; y: number; w: number; h: number; label: string }
const nodes: Node[] = [
  { x: 60,  y: 60,  w: 140, h: 56, label: "水泵" },
  { x: 260, y: 60,  w: 140, h: 56, label: "冷却塔电机温度传感" },
  { x: 460, y: 60,  w: 140, h: 56, label: "三号车间空压机冷却循环水泵出口压力监测点" },
];

function render(): void {
  ctx.clearRect(0, 0, cssWidth, cssHeight);
  for (const n of nodes) {
    ctx.fillStyle = "#1a2740";
    ctx.fillRect(n.x, n.y, n.w, n.h);
    ctx.strokeStyle = "#00ff88";
    ctx.strokeRect(n.x, n.y, n.w, n.h);
    drawNodeLabel(n.label, n.x, n.y, n.w, n.h);
  }
  ctx.fillStyle = "#8fa3c0";
  ctx.font = "12px sans-serif";
  ctx.fillText(`排版缓存条目: ${layoutCache.size}(拖节点试试流畅度)`, 12, cssHeight - 12);
}
render();

// 拖拽(Day 34/38 的老套路)
let dragIdx = -1, offX = 0, offY = 0;
canvas.addEventListener("mousedown", (e) => {
  const r = canvas.getBoundingClientRect();
  const px = e.clientX - r.left, py = e.clientY - r.top;
  dragIdx = nodes.findIndex((n) => px >= n.x && px <= n.x + n.w && py >= n.y && py <= n.y + n.h);
  if (dragIdx >= 0) { offX = px - nodes[dragIdx].x; offY = py - nodes[dragIdx].y; }
});
window.addEventListener("mousemove", (e) => {
  if (dragIdx < 0) return;
  const r = canvas.getBoundingClientRect();
  nodes[dragIdx].x = e.clientX - r.left - offX;
  nodes[dragIdx].y = e.clientY - r.top - offY;
  render();
});
window.addEventListener("mouseup", () => { dragIdx = -1; });

观察点:拖拽过程中 layoutCache.size 稳定不涨(同样的标签反复渲染全命中缓存)——这就是"排版一次、绘制万次"。


八、常见坑点与最佳实践

# 症状 解法
1 测量前忘了设 font 换字体后排版全错(用旧字体量的宽) measureText 前必设 ctx.font
2 textBaseline 默认 alphabetic 当 middle 用 文字偏下 显式设置;或用 actualBoundingBox 精算
3 省略号忘了给自己留宽度 加 “…” 后反而超宽 先量省略号宽,文字可用宽 = maxWidth - ellW
4 英文单词被逐字劈开 “pump” 变 “pu/mp” 逐词断行 + 超长词兜底硬拆
5 每帧重新排版 拖拽多节点时掉帧 layoutCache(font+width+text 为键)
6 缓存无限膨胀 大屏长跑内存泄漏 超限清空(近似 LRU)
7 textAlign 状态泄漏 后面的图形文字错位 排版函数里 save/restore 或每次显式设置
8 行高用字号本体 中文上下行贴脸 lineHeight = 字号 × 1.4~1.6
9 高分屏字体发虚 字糊成一团 检查 DPR 适配是否生效(Day 29)

九、自测挑战

  1. 手算题:14px 中文字体,容器宽 100px,文字"智能制造执行系统数据采集模块"(15 字,每字约 14px)。写出 wrapCJK 的分行结果。
  2. 实现题:给 ellipsize 增加 position: "start" | "middle" | "end" 参数——省略号在头部(“…集模块”)、中部(“智能执…集模块”)、尾部(“智能制造执…”)。写出中部版的关键代码。
  3. 改错题:下面的垂直居中代码为什么偏下?修复它。
ctx.textBaseline = "alphabetic";
ctx.fillText(text, cx, boxTop + boxHeight / 2);
  1. 设计题:节点的标签支持"标题 + 描述"两段式(标题粗体 14px 不换行截断,描述常规 12px 最多 2 行)。设计缓存 key 并写出 drawNodeLabel 的升级版。
  2. 思考题:为什么缓存策略用"超限清空"而不是逐条过期?(提示:排版计算的成本量级 vs 缓存重建的成本量级;标签内容的访问模式。)
  3. 进阶题:实现竖排文字(每字一行,从上到下)。思考:中文竖排简单在哪?英文竖排(旋转 90°)怎么和中文混排?

十、总结与知识图谱

Day 43 文本渲染进阶
├── 认知
│   └── Canvas 立即模式 → 排版自己写
├── 测量 measureText
│   ├── width / ascent / descent
│   └── 精确垂直居中(actualBoundingBox)
├── 换行算法
│   ├── 中文逐字贪心
│   ├── 英文逐词 + 超长词硬拆
│   └── 混排 tokens 生成器
├── 截断
│   ├── 单行省略号(二分查找 + 给 "…" 留宽)
│   └── 多行 N 行截断(末行再 ellipsize)
├── 排版
│   ├── 行高 1.4 倍惯例
│   └── 整块垂直居中的基线推导
├── 性能
│   └── layoutCache(font|width|text 键)+ 超限清空
└── 实战
    └── drawNodeLabel 引擎(BOSS 战节点渲染直用)

明天预告(Day 44)裁剪 clip——把路径变成"模板",之后画的东西只在模板内可见。扇形进度环(仪表盘的兄弟)、圆形头像、雷达图,这些"非矩形可视区域"全靠它。还记得 Day 36 说过的"裁剪区域也被 save/restore 管理"吗?明天正式回收这个伏笔。

评论