旧印未损,新印先成。一枚数字印章值得被重刻一次——不是因为它坏了,而是因为它太重了。
一支独大的旧版
shuimo-core 的初代印章模块叫做 Stamp,一个文件、一个类、两千四百行。它把"读字体、量字宽、画边框、加噪声、套滤镜、拼 SVG 字符串"全压在一处函数里。能跑,也能用,但每动一处就要在两千行里穿针引线——加一个新形状,得改三个分支;想给文字加几何角化,要在 SVG filter 和路径生成两边各塞一段代码;同页放两枚印章,第二枚的 filter ID 撞了第一枚的,结果一红一灰,像被人偷换了印泥。
把这堆问题拍在桌上一看,与其继续在一个文件里给它打补丁,不如把它拆成一座小楼。这就是 stampV2——同一个 shuimo-core 包里,与 v1 并列共存,让现有用户慢慢迁移。
一座十层的小楼
v2 不是 v1 的小修小补,而是把"一次生成"重排成"流水分层"。每一层都有名字、有目录、有自己的单测:
stampV2/
├── seal.ts Pipeline 总入口
├── types.ts 全部参数与返回结构
├── text/ 字形提取 · 角化刀刻 · 篆体风格档
├── layout/ 栅格排版(竖排右起 / 环排)
├── border/ 形状构建 · 边框磨损
├── texture/ SVG 滤镜链(印泥纹理)
├── geometry/ 路径扁平化、偏移、布尔运算
├── render/ 分层 SVG 组装
└── internal/ 尺寸自适应、PRNG 工具一枚印章从 options 到 SealResult 要走十段:参数标准化 → 字形提取 → 角化刀刻 → 栅格布局 → 边框构建 → 边框磨损 → 滤镜定义 → 分层渲染 → ID 隔离 → 结果打包。每一段不到 300 行,每一段都能独立测试。
最重要的是,每一段都不知道下一段的存在——这种"互不相认"的好处,是想往中间塞一段、或者抽掉一段,都不会牵动整栋楼。
流水线的人格
function pipeline(font, options): SealResult {
// 1. 字形 + 角化(篆体风格驱动 angularize 三参数)
const profile = getScriptProfile(options.script)
const cells = layoutGrid(textInput, area, direction)
.map(c => ({ ...c, commands: angularize(font.glyph(c.char), profile) }))
// 2. 边框:先选形状,再加磨损
const border = buildBorder(shape, { width, height, thickness })
const eroded = erodeBorder(border, { roughness, size }, prng)
// 3. 渲染:印泥滤镜 + 分层 SVG,ID 用 (seed,text,shape) 哈希隔离
return renderSvg({
cells, borderPoly: eroded, inkColor,
filterDefs: inkFilterDefs(...),
idPrefix: hashId(seed, text, shape),
})
}整条流水线最让我满意的,是 PRNG 的"分流"。v1 里所有随机数挤一条通道,调一个参数全部连动;v2 里给三段关键流程各拨了一份 salt——SALT_SHAPE 给边框、SALT_CARVE 给刀刻、SALT_EROSION 给磨损——同一个 seed 下,你只想换边框的破损样式而不动字,可以做到。
五档篆体:从金文到九叠
v1 时代,文字"刻"得多深、多碎、多锐,靠一个 textCarving 枚举三选一:normal / strong / stone-cut。能用,但缺粒度——金文的圆浑、九叠篆的方正棱角,靠三档是分不出来的。
v2 把它换成一张"风格档"——SealScript 五选一,每一档对应三个 angularizeCommands 的具体参数:
| 风格 | 字面 | grid | jitter | pull | 视感 |
|---|---|---|---|---|---|
jinwen | 金文 | 1.8 | 0.6 | 0.35 | 圆润,铜器铸字感 |
dazhuan | 大篆 | 1.5 | 0.8 | 0.40 | 略带石刻凿痕 |
xiaozhuan | 小篆 | 1.4 | 0.9 | 0.42 | v2 的默认基线 |
jiudiezhuan | 九叠篆 | 0.6 | 1.2 | 0.55 | 笔画方正叠折,宋元官印风 |
custom | 自定义 | — | — | — | 全权交给 carving.intensity |
grid 是把贝塞尔曲线"量化"到多大的栅格里——值越小,曲线越像被刀切成的折线;jitter 是给量化后的顶点抖一抖,模拟刀崩;pull 是把控制点往两端拉直,让原本柔和的转折变得方硬。三个参数互相牵制,调出来的不是滤镜效果,而是字形本身的折线化——刀真的下到了字里。
九叠篆那档参数最极端:grid 缩到 0.6,意味着哪怕是一段笔直的横画都会被切成两三个小段;pull 拉到 0.55,所有圆弧的控制点都几乎被强行掰直。结果就是 SVG 路径里看不到 C 命令了——只剩 L,整整齐齐的折线。
同一份配置,两种尺寸
v2 还想解决一个 v1 留下的小痛点:同一个 intensity,在 100px 的印章上看着锐到扎眼,在 480px 的印章上又软得像云。原因是噪声振幅是绝对像素,频率也是绝对像素,缩放印章时纹理的"相对密度"自然失衡。
internal/visualScale.ts 给出了一个非常小的修正:
const REF_SIZE = 480
export function scaleForSize(size: number) {
const s = size / REF_SIZE
return {
lengthScale: s, // 位移幅度按比例缩
frequencyScale: 1 / s, // 噪声频率反向放大
}
}参考尺寸 480,用它锚定一切。位移幅度(用于边框磨损、刀刻抖动)× lengthScale,缩小后位移自然变小;噪声频率(用于 SVG filter 的 baseFrequency)× frequencyScale,缩小后频率提高、纹理变密。两条乘数一对冲,"相对密度"在所有尺寸下保持一致。
意义在哪?intensity = 0.5 这种语义从此可以从 v1 的"约等于 5px 的扰动幅度"换成 v2 的"中等强度的相对扰动"——参数有了平台无关的意义,文档和示例不必再为不同尺寸写不同的推荐值。
多印同框:被 ID 撞坏的旧账
v1 时代,把两枚印章放在同一个页面上有概率出 bug。原因是 SVG <filter> 的 ID 写死了 "stamp-text-texture"、"stamp-ink-texture",第二枚印章的 <defs> 把第一枚的覆盖掉,结果整页都用第一枚的滤镜参数渲染。这种问题不容易出现在博客文章里——一篇文章一枚印章——但 gallery 页面、双印对照、动态切换的演示场景下立刻就显现。
v2 的解法是给每个滤镜套一层"命名空间"前缀:
const idPrefix = `${seed}_${hash(text, shape, mode, size)}`
// 所有 filter / clipPath / linearGradient 的 id 都用 idPrefix 加点尾巴seed 给随机性兜底,hash(text, shape, mode, size) 给参数兜底。两枚配置不同的印章绝不会撞 ID;两枚配置完全相同的印章——本来就该长得一模一样,撞了也无所谓。
StampV2Gallery.vue 这个 demo 页面一次性渲染 13 种布局 × 7 种形状 × 阴阳两种模式,整面墙 182 枚印章,互不影响。
边框的两层磨损
border/erosion.ts 是 v2 里我个人最喜欢的一段。它把"印章久用磨损"建模成两层叠加:
第一层是连续扰动:把外边界顶点密集化,每个顶点沿法线方向被 Simplex 噪声推一推。推的方向只取"向内"——印章只会变小,不会凸起,这点和 v1 一致,是真实印章物理磨损的规律。
第二层是离散缺口:用伪随机数生成几个三角形,用布尔减法从边框上切走。这是模拟那种"摔了一下、磕了一角"的硬伤,比连续噪声更具突变感。
function erodeBorder(poly, { roughness, size }, prng) {
const amp = roughness * borderThickness * scaleForSize(size).lengthScale
const wavy = densifyAndPushAlongNormals(poly, amp, prng)
const notches = generateRandomTriangles(poly, count = 2 + prng.int(3))
return polygonDifference(wavy, notches)
}两层模型的好处是参数语义清晰——roughness 一个值同时控制连续磨损的振幅和缺口的数量,调起来很顺手。v1 里类似效果要靠 noiseAmount、borderPoints、外加一段"角落噪声为零"的特判,参数多,行为不直观。
阴阳与多边形
v2 还顺手补齐了 v1 缺失的几样:
- 阴章(
mode: "yin"):红底白字,整面朱砂只在文字处留白。v1 默认只支持阳章,阴章要靠用户自己反色。v2 把它做成一阶 API,配合"双层 yin 渲染"——底层一张红色填充矩形被边框 clipPath 限定形状,上层文字以白色挖空——可以正确处理边框磨损后的不规则外形。 - 多边形(
shape: { kind: "polygon", sides: 3..12 }):三角、五角、六角、十二角,全部都能画。orientation还区分"平顶"和"尖顶",比如六角形可以是蜂窝状的平顶,也可以是雪花的尖顶。 - 椭圆与长方(
aspect参数):长宽比从此可以自定义,不再是固定的 1:0.6。 - 环排布局(
direction: "circular"):文字沿圆周绕一圈,是那种"国之玺印"式的官印感。
这些新增是面向"花样印章"用户的——博客文章里我大概不会用十二边形印章,但 gallery 和 playground 上排出一墙花式印章,是相当过瘾的事。
性能:拆得更细,反而更快
直觉上拆成十段函数应该比一段大函数慢——多了函数调用开销、多了中间数据结构、多了 GC 压力。实测反过来。stampV2/stamp-v1-vs-v2.bench.ts 给出的数字:
| 场景 | v1 | v2 | 提速 |
|---|---|---|---|
| 单印章 | 11.1ms | 8.9ms | 25% |
| 50 个不同印章 | 568ms | 451ms | 26% |
| 长文本(多字) | 14.0ms | 10.4ms | 34% |
提速来源大致有三块:
- V8 友好——小函数容易被 JIT 内联和优化,2400 行的大函数则容易触发优化抑制。
- 更少字符串拼接——v1 在 SVG 字符串上做了大量
+=拼接,v2 改成数组push+ 末尾一次join,GC 压力低很多。 - 更早的 short-circuit——v2 的 pipeline 在每一阶段都能判定"无需此层"(比如
roughness === 0时整个 erosion 阶段跳过),v1 的单体函数里这类剪枝写得不彻底。
想看上面所说的能力实物对照?整面墙的精选印章在 印谱 里——文字布局、阴阳两面、形状六法、多边形、环排官印、磨损梯度,本文写到的每一档都有对应展品。
尾声
重构这件事,技术圈一直有两派意见。一派说:"能跑就别动,重构是给自己挖坑。"另一派说:"不重构,旧债迟早压死你。"两边都有道理,但都没说到要害——重构其实是另一种创作。
v1 是一首长诗,从头到尾一气贯通,但回头看时连标点都难改;v2 是把同一首诗拆成几个独立的句子,每句都自成一首小诗,又能拼回去成原诗。同样的内容,结构不同,能写的东西就完全不同。
九叠篆是这次重构的小彩蛋。这种字体最盛于宋元官印,笔画一律方正,叠折往复,远看像迷宫,近看是字。它需要的角化参数极端,是 v1 的几何 + 滤镜单层渲染根本做不出来的——只有把"字形几何变形"和"印泥纹理滤镜"彻底拆成两层,九叠才能立得起来。
新印未必比旧印好用,但能盖出旧印盖不出的字。
