Canvas 文本与图片 — 排版锚点与九参数裁剪,附"画布污染"大坑
前四天画的是"形",今天补上"字"和"图"。文本:font 的 CSS 简写顺序、textAlign/textBaseline 构成的锚点体系(大屏数字对齐的关键)、measureText 的测量(自适应缩放文本的原理)。图片:drawImage 的三种重载(重点是九参数裁剪——雪碧图、视频帧截图的基础)、图片加载的异步管理,以及最隐蔽的坑:tainted canvas(画布被污染)——一张没配 CORS 的图片会让你整块画布失去 toDataURL 导出能力。产出:画布批量水印 + 图片九宫格裁剪。
目录
- 一、文本渲染基础
- 二、锚点体系:textAlign 与 textBaseline
- 三、measureText:让文本"量体裁衣"
- 四、drawImage 的三种重载
- 五、九参数裁剪:源矩形大法
- 六、图片异步与 tainted canvas
- 七、实战:批量水印
- 八、实战:九宫格裁剪
- 九、类比记忆:印章与裁纸刀
- 十、常见坑点与最佳实践
- 十一、自测挑战
- 十二、总结与知识图谱
一、文本渲染基础
// 文本两件套: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 周的判别联合给动画状态建模;产出跟随鼠标的粒子尾巴。