落梅听风雪,隔窗枕雨眠
← 归去来兮隔窗听雨

数字印章:用代码刻一方篆印

2026/4/19 · tech · 阅读 - 次

一幅字画落款之后,最后总要盖一方印。那一点红是收尾,有时也是整幅画里最跳的一笔。我想试试能不能用代码把它刻出来。

阴和阳

中国印章分阴阳。阴章(白文)的字是凹刻的,盖出来底色是朱砂红、文字留白;阳章(朱文)正相反,字凸出来显红,底子留白。这套形制从秦汉的封泥一直用到明清的闲章,几千年没断过。

shuimo-core 里,这一对就是 type: "yin"type: "yang",改一行配置就能切。

从文字到路径

第一步是排版。篆刻讲究竖排右起:每列自上而下,列与列从右往左排。算法拿 fontSize 当基准,所有间距尺寸都按它的比例算:

typescript
// 列宽   = fontSize × 0.55(估算)或浏览器 getBBox / fontkit 实测
// 字高   = fontSize × 1.1(含上下安全间隔)
// 列间距 = fontSize × 0.02(columnSpacing 默认值)
// 字间距 = fontSize × 0.05(characterSpacing 默认值)
// 内边距 = fontSize × 0.02(paddingX / paddingY 默认值)

好处是印章放大缩小都不走形,做成匾额那么大、或者缩成一个落款,比例都还在。每个参数也都留了 *Px 的绝对值版本(paddingXPxcornerRadiusPx 之类),想钉死像素随时能覆盖。

边框上的噪声

真实印章的边缘从来不是平整的。石头有疏密,刀有顿挫,再加上几十上百年的磨损,边框上全是不规则的豁口。shuimo-core 用 Perlin 噪声来仿这种残破。

核心参数有三个:

  • noiseAmount:噪声幅度,控制边框破得多狠
  • borderPoints:采样点数,决定边缘有多糙
  • cornerRadius:圆角半径,管转角的形状
typescript
// 噪声只取法线的「向内分量」——只有凹陷,没有凸起
// 角落处噪声趋零,保证四角完整——这与真实印章的磨损规律一致
const cornerFactor = edgeProgress // 0→边角, 1→边中
const offset = noise * cornerFactor // 角落无噪声,边中最大

// 四角圆角各自独立、±20% 随机偏移,避免几何上的完美对称
const cornerRadii = corner * (1 + (rand() - 0.5) * 0.4)

有意思的是最后那条规律:印章再破,四个角通常是完整的。所以代码里让角落的噪声趋近零。

五种形状

方形之外还有别的。shuimo-core 支持五种:

形状说明
auto不规则梯形,左右高度微异,最接近手工篆刻
square正方形加噪声
rectangle长方形加噪声
circle圆形,沿径向施加噪声
ellipse胶囊形,直边与弧端组合

我自己最喜欢 auto:它让左右两边高度差一点点,盖出来有种轻微的歪,像手工刻石时怎么都对不齐的那种手感。

刻字的三种刀法

字怎么「刻」,由 textCarving 控制,分三档:

  • normal:清晰可读,如新刻之印
  • strong:边缘粗粝,如久经使用
  • stone-cut:碎裂质感,如汉印出土

stone-cut 这档最费功夫。它用 fontkit 把字体(包括 woff2 那层 brotli 编码)解析成字形路径,再用 angularizeGlyphPath() 把曲线量化到 1.4px 的网格上,对控制点加大约 0.9px 的伪随机抖动,模拟刀崩石面时崩出来的碎口。注意这不是套了层滤镜,而是真的去改路径本身——刻刀下到了字里。

三档刀法对应三套滤镜参数:coreErode(保留字芯的腐蚀半径)、edgeFrequency / edgeDisplacement(边缘湍流的密度与位移)、chipFrequency / chipThreshold(崩片的频度与阈值),还有 grainFrequency(颗粒底纹)。stone-cut 额外接了一段 feComponentTransfer 的 discrete 表,把湍流值阶梯化,让崩裂的边像真被凿过一样有棱角。

字形 Worker:别让主线程干等

woff2 里的 brotli 解码不便宜,一份字库头一次解析常常把主线程堵上几百毫秒。所以 shuimo-core 把整段 fontkit 解析挪进了 Web Worker:

typescript
const stamp = new Stamp({
  text: ['落梅,听风,雪'],
  fontUrl: '/fonts/zhanku-zhuanshu.woff2',
  fontWorker: new Worker(
    new URL('@jobinjia/shuimo-core/stamp/font-worker', import.meta.url),
    { type: 'module' },
  ),
})

Worker 那头按去重后的字符表回传 commandsAtUnitsPerEm,也就是在 unitsPerEm 设计网格下归一好的字形指令。主线程拿到后只要 fontSize / unitsPerEm 缩放一次,就能还原任意尺寸,fontkit 全程不碰 UI 线程。同一个 fontUrl + 字符集 还会进 FONT_CACHE,重盖一次连消息往返都省了。

SVG 滤镜:墨与石的质感

最终的质感是三层 SVG 滤镜叠出来的:

  1. stamp-ink-texture:印泥质感,用分形湍流做出墨色浓淡不匀
  2. stamp-border-texture:边框的石质纹理
  3. stamp-text-texture:文字的刻蚀,含侵蚀、崩片和颗粒遮罩
xml
<!-- 分形湍流 + 位移映射 = 自然的墨色晕散 -->
<feTurbulence type="fractalNoise" baseFrequency="0.04" />
<feDisplacementMap scale="10" />
<!-- 印泥位移后用 feComposite operator="in" 裁回原始轮廓,
     保证「只凹不凸」——这一刀切下去,边只会更内敛 -->

所有 baseFrequencyscale 都按 fontSize / 70 等比缩放(代码里叫 filterScale),这样 35px 的小印和 280px 的大印纹理密度才一致。三层各管一摊,叠起来就是一枚有年头的印。

种子

印章生成带一个 seed 参数。同一个种子,不管在浏览器还是 Node.js,结果都完全一致:所有浮点数走 fmtNum() 格式化到固定精度,噪声也由种子驱动的伪随机数生成器控制。

所以一方数字印章能跟真印一样——每次盖下去,都是同一方。

把 Perlin 搬进 Rust

最早边框噪声全在 JS 里跑:三线性插值、12 方向梯度、几百次 noise3D 调用,一次盖章就能把主线程顶到 16ms 的边上。后来写了个 Rust crate shuimo-noise,把 square / rectangle / circle / ellipse 这四种规则形状的边框生成整段搬进了 WebAssembly:

typescript
// StampWasm.ts —— 没有 fetch,没有副本
const bin = atob(WASM_NOISE_BASE64)           // 产物内嵌为 base64
const buf = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
initSync({ module: buf.buffer })              // 现代 initSync 签名

const path = stamp_circle_path(
  radius, borderPoints, noiseAmount,
  seed, regular, /*octaves=*/4, /*persistence=*/0.5,
)

WASM 模块直接以 base64 打进 JS 产物,没有额外网络请求,不用操心 MIME,也没有 CORS 的麻烦。auto 梯形和字形渲染还留在 JS 这边(那边要跟 SVG 字符串拼),但纯计算密集的循环已经交给原生代码了。

尾声

印者,信也。

古人盖印为信,凭章为凭。今天我们换成用 Perlin 噪声仿石头的残破,用贝塞尔曲线描篆书的筋骨,用 SVG 滤镜还原印泥的温润。工具一直在换,方寸之间那点庄重和信诺没怎么变。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.4.1
归去来兮 ←