【Canvas 2D】day33-text-images

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

Canvas 文本与图片 — 排版锚点与九参数裁剪,附"画布污染"大坑

前四天画的是"形",今天补上"字"和"图"。文本:font 的 CSS 简写顺序、textAlign/textBaseline 构成的锚点体系(大屏数字对齐的关键)、measureText 的测量(自适应缩放文本的原理)。图片:drawImage 的三种重载(重点是九参数裁剪——雪碧图、视频帧截图的基础)、图片加载的异步管理,以及最隐蔽的坑:tainted canvas(画布被污染)——一张没配 CORS 的图片会让你整块画布失去 toDataURL 导出能力。产出:画布批量水印 + 图片九宫格裁剪。


目录


一、文本渲染基础

// 文本两件套:fillText(填充)与 strokeText(描边)
ctx.font = "bold 32px 'Microsoft YaHei', sans-serif";   // ① 先设字体(CSS font 简写)
ctx.fillStyle = "#e6f1ff";
ctx.fillText("设备温度", 100, 100);                      // ② 在锚点写填充文本

ctx.strokeStyle = "#00ff88";
ctx.lineWidth = 1;
ctx.strokeText("描边字", 100, 140);                      // 空心描边字
// fill + stroke 组合 = "描边+填充"的艺术字(先 stroke 后 fill 边缘更干净)
// ⚠ font 必须是完整的 CSS 简写,顺序错了静默失效:
ctx.font = "32px bold";        // ❌ 无效(bold 必须在字号前)
ctx.font = "bold 32px";        // ❌ 无效(font-family 不可省略!)

// 正确语法顺序(可选的在前,必须的在后):
ctx.font = "[style] [variant] [weight] [size/line-height] [family]";
ctx.font = "italic bold 24px sans-serif";              // ✅
ctx.font = 'bold 32px "Microsoft YaHei", sans-serif';  // ✅(带引号的字体名 + 回退链)

二、锚点体系:textAlign 与 textBaseline

2.1 问题的由来

// fillText(text, x, y) 里的 (x,y) 是"锚点"—— 但锚在文本的哪个部位?
// 默认:x 是文本【左边缘】、y 是【基线】(文字底座线,不是底边!)

// 基线在哪?—— 中文没有"下伸部",但英文的 g/y/p 会垂到基线之下
// "大屏数字想垂直居中在格子里" → 用默认基线怎么算 y?—— 算不准!

2.2 textBaseline 的七种取值

ctx.textBaseline = "top";         // 锚点在文本【顶边】→ y 是顶部
ctx.textBaseline = "middle";      // 锚点在【垂直中心】→ 居中场景的救星 ⭐
ctx.textBaseline = "bottom";      // 锚点在【底边】(含下伸部)
ctx.textBaseline = "alphabetic";  // 默认:字母基线(g/y 垂到线下)
ctx.textBaseline = "hanging";     // 悬挂线(部分亚洲文字用)
ctx.textBaseline = "ideographic"; // 表意基线(中日韩)

// 实用组合速记:
// 单元格内居中     → textAlign: "center" + textBaseline: "middle"
// 图表轴刻度在下方 → textAlign: "center" + textBaseline: "top"
// 标签左对齐顶格   → textAlign: "left" + textBaseline: "top"

2.3 textAlign 的五种取值

ctx.textAlign = "left";     // 锚点在文本左边缘(x 向右展开)
ctx.textAlign = "center";   // 锚点在水平中心 ⭐
ctx.textAlign = "right";    // 锚点在右边缘(x 向左展开)
ctx.textAlign = "start";    // 书写方向起点(中文/英文=左,阿拉伯文=右)
ctx.textAlign = "end";      // 书写方向终点
// 国际化项目用 start/end(跟书写方向走),普通场景用 left/center/right
// 肉眼实验(今天必做):画一个十字准星 + 各种锚点的文本
ctx.strokeStyle = "#3a4a6a";
ctx.beginPath();
ctx.moveTo(200, 50); ctx.lineTo(200, 250);   // 竖线
ctx.moveTo(100, 150); ctx.lineTo(300, 150);  // 横线
ctx.stroke();

ctx.fillStyle = "#00ff88";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("middle", 200, 150);   // 正好在十字交点居中

ctx.textBaseline = "top";
ctx.fillText("top", 200, 150);      // 在交点【下方】顶格

ctx.textAlign = "left";
ctx.fillText("left", 200, 150);     // 从交点【向右】展开

三、measureText:让文本"量体裁衣"

// measureText 返回文本的度量信息(宽度最常用):
ctx.font = "bold 24px sans-serif";
const metrics = ctx.measureText("设备温度 65℃");
metrics.width;        // 文本的像素宽度
// metrics.actualBoundingBoxAscent / Descent  // 实际绘制区域的高度(精度居中用)

// ⚠ 测量必须在【相同 font 设置】下进行 —— 换字体重测!
/**
 * 场景 1:文本自适应缩放(大屏数字塞进固定格子)
 */
function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): number {
  let fontSize = 64;                                    // 从大往小试
  do {
    ctx.font = `bold ${fontSize}px sans-serif`;
    if (ctx.measureText(text).width <= maxWidth) break;
    fontSize--;
  } while (fontSize > 12);
  return fontSize;
}

/**
 * 场景 2:自适应背景框(标签底色比文字宽一圈)
 */
function drawLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
  ctx.font = "14px sans-serif";
  const w = ctx.measureText(text).width;
  // 背景框(比文本宽 16、高 24):
  ctx.fillStyle = "rgba(0, 255, 136, 0.15)";
  ctx.fillRect(x - 8, y - 16, w + 16, 24);
  // 文本(锚点左对齐):
  ctx.fillStyle = "#00ff88";
  ctx.textAlign = "left";
  ctx.textBaseline = "middle";
  ctx.fillText(text, x, y - 4);
}

四、drawImage 的三种重载

// ===== 重载 1:五参数(原图 + 目标位置尺寸)=====
ctx.drawImage(image, dx, dy);
// 把整张图原样放到 (dx, dy)

// ===== 重载 2:五参数(缩放)=====
ctx.drawImage(image, dx, dy, dw, dh);
// 整张图缩放到 dw×dh(不裁剪,可能变形 —— 宽高比变了)

// ===== 重载 3:九参数(源裁剪 + 目标放置)⭐ =====
ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
//                    └─源区域─┘   └──目标区域──┘
// 从原图的 (sx,sy) 裁出 sw×sh 的一块,画到画布的 (dx,dy) 缩放成 dw×dh
九参数的直觉图:

   原图(source image)              画布(destination)
  ┌──────────────────┐            ┌──────────────────┐
  │        ┌────┐    │            │   ┌───────┐      │
  │        │sw×sh│    │  ─────→   │   │ dw×dh │      │
  │        └────┘    │            │   └───────┘      │
  │      (sx,sy)     │            │  (dx,dy)         │
  └──────────────────┘            └──────────────────┘
     先裁后缩:原图的一小块 → 画布的一个位置(可再缩放)

用途:雪碧图取帧、视频帧截图、图片局部放大镜

五、九参数裁剪:源矩形大法

5.1 雪碧图取帧(动画的基础)

/**
 * 从横向雪碧图取第 n 帧(帧宽 frameW)
 * 雪 sprite.png:8 帧横排,每帧 96×96
 */
const sprite = new Image();
sprite.src = "./device-sprite.png";
sprite.onload = () => {
  const frameIndex = 3;   // 第 4 帧
  ctx.drawImage(
    sprite,
    frameIndex * 96, 0, 96, 96,    // 源:第 4 帧所在的矩形
    100, 100, 96, 96               // 目标:原尺寸放画布上
  );
};
// Day 34 动画循环 + 逐帧换 frameIndex = 序列帧动画(设备状态小图标动起来)

5.2 保持宽高比的"cover"缩放(头像裁剪)

/**
 * 图片以 cover 模式填充目标区域(不变形、居中裁剪 —— CSS object-fit: cover 的 Canvas 版)
 */
function drawImageCover(
  ctx: CanvasRenderingContext2D,
  img: HTMLImageElement,
  dx: number, dy: number, dw: number, dh: number
): void {
  const imgRatio = img.width / img.height;
  const boxRatio = dw / dh;

  let sw: number, sh: number;
  if (imgRatio > boxRatio) {
    // 图更宽 → 裁左右:
    sh = img.height;
    sw = sh * boxRatio;
  } else {
    // 图更高 → 裁上下:
    sw = img.width;
    sh = sw / boxRatio;
  }
  const sx = (img.width - sw) / 2;    // 居中裁剪
  const sy = (img.height - sh) / 2;

  ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
}

六、图片异步与 tainted canvas

6.1 图片加载的异步管理

// ⚠ drawImage 一个没加载完的图片 = 静默失败(什么都不画):
const img = new Image();
img.src = "./photo.png";
ctx.drawImage(img, 0, 0);        // ❌ 图片还是空的(0×0)

// ✅ onload 里画:
img.onload = () => ctx.drawImage(img, 0, 0);
// 批量图片 → Promise.all 统一等待:
function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });
}
const [a, b, c] = await Promise.all([
  loadImage("./a.png"), loadImage("./b.png"), loadImage("./c.png"),
]);

6.2 tainted canvas(画布被污染)

// ⚠ 本周最隐蔽的坑 —— 事故现场:

const img = new Image();
img.src = "https://别的域名.com/photo.png";   // ❌ 没设 crossOrigin 的跨域图
img.onload = () => {
  ctx.drawImage(img, 0, 0);                    // ✅ 能画出来(画面正常!)

  canvas.toDataURL();                          // ❌ SecurityError!
  // "Tainted canvases may not be exported."
  // 画布一旦画了跨域图(未经 CORS 授权),整块画布被"污染"
  // → toDataURL / getImageData 全部禁用(防止偷读他人像素数据)
  // Day 35 画板的"导出 PNG"功能会当场爆炸
};

// ✅ 修复:加载前声明 crossOrigin(服务器必须返回 CORS 头):
const img = new Image();
img.crossOrigin = "anonymous";                 // ← 关键行(在 src 之前!)
img.src = "https://cdn.example.com/photo.png"; // 该 CDN 必须配 Access-Control-Allow-Origin

// ✅ 同源图片(自己的服务器/本地文件)没有这个问题

七、实战:批量水印

// day33-watermark.ts —— 平铺斜排水印(工业系统截图的标配)
import { bootCanvas } from "./canvas-boot.js";

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

/**
 * 平铺水印:旋转 -20°,网格状铺满全画布
 */
function drawWatermark(text: string): void {
  ctx.save();   // 保存状态(旋转要复位)

  // ① 定位到画布中心再旋转(旋转围绕"当前原点"进行 —— 第 6 周正式课的预告):
  ctx.translate(width / 2, height / 2);
  ctx.rotate((-20 * Math.PI) / 180);

  // ② 水印样式:大字 + 低透明度:
  ctx.font = "20px sans-serif";
  ctx.fillStyle = "rgba(255, 255, 255, 0.08)";   // 8% 白 —— 存在但不抢戏
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";

  // ③ 网格平铺(间距略大于文本宽,避免叠字):
  const stepX = 220, stepY = 120;
  const cols = Math.ceil(width / stepX) + 2;
  const rows = Math.ceil(height / stepY) + 2;
  for (let r = -rows; r <= rows; r++) {
    for (let c = -cols; c <= cols; c++) {
      // 奇偶行错位半格(砖缝式排列更美观):
      const offset = r % 2 === 0 ? 0 : stepX / 2;
      ctx.fillText(text, c * stepX + offset, r * stepY);
    }
  }

  ctx.restore();   // 复位(translate/rotate/font/alpha 全部还原)
}

// ===== 使用:先画内容,再盖水印 =====
ctx.fillStyle = "#0a1628";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#00ff88";
ctx.font = "bold 28px sans-serif";
ctx.fillText("机密画面:车间 A 区实时监控", 40, 60);

drawWatermark("内部资料 · 禁止外传");

八、实战:九宫格裁剪

// day33-nine-grid.ts —— 把一张图切成 3×3 的九块(drawImage 九参数的直观训练)
import { bootCanvas } from "./canvas-boot.js";

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

function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`加载失败: ${src}`));
    img.src = src;
  });
}

const img = await loadImage("./factory.jpg");   // 任意 600×400 左右的图

// ===== 九宫格参数 =====
const gap = 6;                       // 块间隙
const cellW = (width - gap * 4) / 3; // 3 列(含边距)
const cellH = (height - gap * 4) / 3;

// 源图上每块的宽高:
const srcCellW = img.width / 3;
const srcCellH = img.height / 3;

for (let r = 0; r < 3; r++) {
  for (let c = 0; c < 3; c++) {
    // 间隔错位排版(第 2 行整体右移一格的"瀑布感",纯练手):
    const dx = gap + c * (cellW + gap) + (r === 1 ? cellW / 2 : 0);
    const dy = gap + r * (cellH + gap);

    ctx.drawImage(
      img,
      c * srcCellW, r * srcCellH, srcCellW, srcCellH,   // 源:第 (r,c) 块
      dx, dy, cellW, cellH                                // 目标:缩放到格子里
    );
  }
}

九、类比记忆:印章与裁纸刀

文本渲染 = 盖印章

- (x, y) 锚点    = 印章的手扶位置(扶哪里决定章落哪)
- textAlign      = 手相对章的左右位置(左扶/中心扶/右扶)
- textBaseline   = 手的上下位置(提着顶盖/掐着腰/托着底座)
- measureText    = 印前量一下章多大(决定垫多大的纸)
- 大屏数字居中难 = 默认扶的是"基线"(托着字母的底座线),
                   而你要的是"掐腰"(middle)—— 换个扶法立刻居中

drawImage = 裁纸刀 + 投影仪
- 五参数  = 整页投影(可缩放)
- 九参数  = 先用裁纸刀从原稿裁下一块(源矩形),
            再投影到幕布的指定区域(目标矩形,可再次缩放)
- tainted canvas = 原稿是"别人家的文件"(无授权跨域图):
            允许你临摹投影(drawImage 正常),
            但禁止复印带走(toDataURL 禁用)—— 版权保护逻辑

十、常见坑点与最佳实践

坑点 1:font 简写语法错误静默失效

ctx.font = "bold";              // ❌ 没有字号字体 → 无效 → 沿用旧 font(10px 默认)
ctx.font = "20px";              // ❌ 缺 font-family → 无效
ctx.font = "bold 20px";         // ❌ 还是缺 family
ctx.font = "bold 20px sans-serif";  // ✅
// 症状:字号"怎么改都不变" —— 先检查 font 简写完整性

坑点 2:textBaseline 的 alphabetic 默认值坑中文居中

// 中文没有下伸部 → 基线视觉上"偏下" → 用默认基线居中永远差一点
ctx.textBaseline = "alphabetic";   // 默认(英文基线)
ctx.textBaseline = "middle";       // ✅ 视觉居中的正解
// 图表刻度、大屏数字:统一 middle + center,一劳永逸

坑点 3:measureText 时 font 与绘制时不一致

ctx.font = "12px sans-serif";
const w = ctx.measureText(text).width;   // 用 12px 量的

ctx.font = "24px sans-serif";
ctx.fillText(text, x - w / 2, y);        // ❌ 用 24px 画 —— 宽度错一倍,居中失效
// 纪律:同一 font 下"量完就画",或封装成函数

坑点 4:drawImage 在图片加载前调用

// 静默失败(连报错都没有)—— 画面上"少了块图"
// 统一走 loadImage 的 Promise 封装(第 6 节),async/await 里按序绘制

坑点 5:忘了 crossOrigin 导致导出爆炸

排查链(toDataURL 报 SecurityError 时):
1. 画布上是否画过跨域图?(哪怕一次)
2. 该图加载时是否设置了 img.crossOrigin = "anonymous"?
3. 图片服务器是否返回了 CORS 响应头?
三问都过 → 重新加载图片重画 → 再导出
(本地开发用同源图片可完全绕开)

十一、自测挑战

挑战 1(基础):锚点练习

在 (200, 150) 画一个十字准星,然后用不同锚点组合画 6 个词:
分别用 (left/right/center) × (top/middle/bottom) —— 肉眼记忆每种锚点的展开方向

挑战 2(进阶):自适应大数字

// 实现 drawBigNumber(ctx, text, cx, cy, boxW, boxH):
// 文本在 box 内水平垂直居中 + 自动缩放到恰好放下(fitText + measureText)
// 场景:大屏的核心 KPI 数字(第 3 个月的常驻组件)

挑战 3(进阶):序列帧动画预习

// 找(或自己做)一张 4 帧的横向雪碧图(如闪烁的警示灯)
// 用九参数 drawImage 显示第 0 帧 —— Day 34 学完 rAF 后把它变成 4 帧循环动画

挑战 4(实验):tainted canvas 复现

1. 找一张外域图(如 picsum.photos),不设 crossOrigin 画上 → toDataURL 报错
2. 设 crossOrigin(picsum 支持 CORS)→ 导出成功
3. 把两次结果(报错截图 + 导出的 PNG)写进博客 —— 这个坑一辈子忘不了

挑战 5(论文级):讲清 cover 裁剪算法

向橡皮鸭解释 drawImageCover:
1. 为什么图比框宽时裁左右、高时裁上下?
2. (img.width - sw) / 2 为什么是居中?
3. 有了它,"contain"模式(完整显示、留白)怎么写?

十二、总结与知识图谱

文本与图片(Day 33)
│
├── 文本渲染
│   ├── fillText / strokeText
│   ├── font 简写顺序(缺 family 静默失效 ⚠)
│   ├── 锚点体系 ⭐
│   │   ├── textAlign —— left/center/right/start/end
│   │   └── textBaseline —— middle 是中文居中正解
│   └── measureText —— 自适应缩放 / 背景框
│
├── 图片绘制
│   ├── 三种重载(3 参数 / 5 参数 / 9 参数)
│   ├── 九参数 = 源裁剪 + 目标放置 ⭐
│   │   ├── 雪碧图取帧
│   │   └── cover 裁剪算法(宽高比比较)
│   └── 异步管理(loadImage Promise 封装)
│
├── tainted canvas ⚠
│   ├── 跨域图未授权 → toDataURL/getImageData 全禁
│   └── 修复:crossOrigin = "anonymous"(src 之前)
│
└── 实战产出
    ├── 平铺斜排水印(translate/rotate 初体验)
    └── 九宫格裁剪

一句话总结:文本的"锚点体系"(align × baseline)决定排版对错的 90%,图片的"九参数裁剪"是雪碧图与 cover 模式的统一解法——而 crossOrigin 必须写在 src 之前这个细节,是保护你画布导出能力的护身符。


明日预告:Day 34 交互与动画——鼠标坐标到画布坐标的换算、requestAnimationFrame 的垂直同步循环、用第 1 周的判别联合给动画状态建模;产出跟随鼠标的粒子尾巴。

评论