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

印之又印:stamp-v2 的重刻记

2026/5/28 · tech · 阅读 - 次

旧印未损,新印先成。一枚数字印章值得被重刻一次——不是因为它坏了,而是因为它太重了。

一支独大的旧版

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 工具

一枚印章从 optionsSealResult 要走十段:参数标准化 → 字形提取 → 角化刀刻 → 栅格布局 → 边框构建 → 边框磨损 → 滤镜定义 → 分层渲染 → ID 隔离 → 结果打包。每一段不到 300 行,每一段都能独立测试。

最重要的是,每一段都不知道下一段的存在——这种"互不相认"的好处,是想往中间塞一段、或者抽掉一段,都不会牵动整栋楼。

流水线的人格

ts
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 的具体参数:

风格字面gridjitterpull视感
jinwen金文1.80.60.35圆润,铜器铸字感
dazhuan大篆1.50.80.40略带石刻凿痕
xiaozhuan小篆1.40.90.42v2 的默认基线
jiudiezhuan九叠篆0.61.20.55笔画方正叠折,宋元官印风
custom自定义全权交给 carving.intensity

grid 是把贝塞尔曲线"量化"到多大的栅格里——值越小,曲线越像被刀切成的折线;jitter 是给量化后的顶点抖一抖,模拟刀崩;pull 是把控制点往两端拉直,让原本柔和的转折变得方硬。三个参数互相牵制,调出来的不是滤镜效果,而是字形本身的折线化——刀真的下到了字里。

九叠篆那档参数最极端:grid 缩到 0.6,意味着哪怕是一段笔直的横画都会被切成两三个小段;pull 拉到 0.55,所有圆弧的控制点都几乎被强行掰直。结果就是 SVG 路径里看不到 C 命令了——只剩 L,整整齐齐的折线。

同一份配置,两种尺寸

v2 还想解决一个 v1 留下的小痛点:同一个 intensity,在 100px 的印章上看着锐到扎眼,在 480px 的印章上又软得像云。原因是噪声振幅是绝对像素,频率也是绝对像素,缩放印章时纹理的"相对密度"自然失衡。

internal/visualScale.ts 给出了一个非常小的修正:

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 的解法是给每个滤镜套一层"命名空间"前缀:

ts
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 一致,是真实印章物理磨损的规律。

第二层是离散缺口:用伪随机数生成几个三角形,用布尔减法从边框上切走。这是模拟那种"摔了一下、磕了一角"的硬伤,比连续噪声更具突变感。

ts
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 里类似效果要靠 noiseAmountborderPoints、外加一段"角落噪声为零"的特判,参数多,行为不直观。

阴阳与多边形

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 给出的数字:

场景v1v2提速
单印章11.1ms8.9ms25%
50 个不同印章568ms451ms26%
长文本(多字)14.0ms10.4ms34%

提速来源大致有三块:

  1. V8 友好——小函数容易被 JIT 内联和优化,2400 行的大函数则容易触发优化抑制。
  2. 更少字符串拼接——v1 在 SVG 字符串上做了大量 += 拼接,v2 改成数组 push + 末尾一次 join,GC 压力低很多。
  3. 更早的 short-circuit——v2 的 pipeline 在每一阶段都能判定"无需此层"(比如 roughness === 0 时整个 erosion 阶段跳过),v1 的单体函数里这类剪枝写得不彻底。

想看上面所说的能力实物对照?整面墙的精选印章在 印谱 里——文字布局、阴阳两面、形状六法、多边形、环排官印、磨损梯度,本文写到的每一档都有对应展品。

尾声

重构这件事,技术圈一直有两派意见。一派说:"能跑就别动,重构是给自己挖坑。"另一派说:"不重构,旧债迟早压死你。"两边都有道理,但都没说到要害——重构其实是另一种创作

v1 是一首长诗,从头到尾一气贯通,但回头看时连标点都难改;v2 是把同一首诗拆成几个独立的句子,每句都自成一首小诗,又能拼回去成原诗。同样的内容,结构不同,能写的东西就完全不同。

九叠篆是这次重构的小彩蛋。这种字体最盛于宋元官印,笔画一律方正,叠折往复,远看像迷宫,近看是字。它需要的角化参数极端,是 v1 的几何 + 滤镜单层渲染根本做不出来的——只有把"字形几何变形"和"印泥纹理滤镜"彻底拆成两层,九叠才能立得起来。

新印未必比旧印好用,但能盖出旧印盖不出的字。

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