跳到主要内容

签名板

签名是 短生命周期、高合规 的路径:点列要能重放,导出要透明底 PNG,面积不能大到把合同 PDF 撑爆。

1. 约束

  • 单指/笔;忽略额外 touch。
  • 不做无限画布。世界 = CSS 像素,DPR 只影响 backing。
  • 空白签:点数 < N 或包围盒面积过小则拒绝。
  • 存盘:点列 + 导出 PNG 哈希,不只存一张无源 PNG(无法证明是用户画的时,至少能在争议里重放)。
export interface SignatureInk {
strokes: { points: { x: number; y: number; t: number }[] }[];
cssWidth: number;
cssHeight: number;
}

2. 绘制

export function paintInk(
ctx: CanvasRenderingContext2D,
ink: SignatureInk,
): void {
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#111";
ctx.lineWidth = 2;
for (const stroke of ink.strokes) {
const path = new Path2D();
const pts = stroke.points;
if (pts.length === 0) continue;
path.moveTo(pts[0]!.x, pts[0]!.y);
for (let i = 1; i < pts.length; i += 1) {
path.lineTo(pts[i]!.x, pts[i]!.y);
}
ctx.stroke(path);
}
}

export function paintSignature(
ctx: CanvasRenderingContext2D,
ink: SignatureInk,
dpr: number,
): void {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, ink.cssWidth, ink.cssHeight);
paintInk(ctx, ink);
}

背景必须透明:合同合成时才能叠在线框上。不要先 fillRect(白)。预览可以用 CSS 棋盘底,不要烧进 PNG。

3. 裁边导出

export function inkBounds(ink: SignatureInk, pad = 8): {
x: number;
y: number;
w: number;
h: number;
} | undefined {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const s of ink.strokes) {
for (const p of s.points) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
}
if (!Number.isFinite(minX)) return undefined;
return {
x: Math.max(0, minX - pad),
y: Math.max(0, minY - pad),
w: Math.min(ink.cssWidth, maxX + pad) - Math.max(0, minX - pad),
h: Math.min(ink.cssHeight, maxY + pad) - Math.max(0, minY - pad),
};
}

export async function exportSignaturePng(
ink: SignatureInk,
dpr: number,
): Promise<Blob> {
const box = inkBounds(ink);
if (!box || box.w < 8 || box.h < 8) {
throw new Error("signature too empty");
}
const canvas = new OffscreenCanvas(
Math.max(1, Math.round(box.w * dpr)),
Math.max(1, Math.round(box.h * dpr)),
);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d unavailable");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(-box.x, -box.y);
paintInk(ctx, ink);
return canvas.convertToBlob({ type: "image/png" });
}

注意:预览清屏走 paintSignature(含 setTransform(dpr));导出走已经设好用户空间的 paintInk,避免 CTM 乘两次。

4. 合规

  • 清屏要二次确认。
  • 不要把签名 canvas 给第三方脚本 toDataURL(指纹 + 泄露)。
  • 横屏 iOS 键盘弹出改变 visualViewport 时重算 css 尺寸,已画点列按比例或保持世界不变——选前者要写进产品:旋转是否作废签名。

5. 失败形态

症状原因
PNG 白底盖住合同导出前 fill 了白
文件过大没裁边,整板 2x DPR 透明 PNG
空签通过只判断 strokes.length>0,点都在同一像素
合成发糊签 3x、合同 1x,再 CSS 拉伸

权威资料

核对日期:2026-08-26