2. 汉字笔顺生成(hanzi-writer)
quick-chinese-transfer 仓库里除了简繁转换的 transfer-core,还藏了一个好玩的模块:hanzi-writer,它能把一个汉字拆成一笔一画,输出带书写动画的 SVG。基于它做的「查笔顺」APP 已经上架 GooglePlay,也提供了在线体验版。
这篇教程介绍它的使用姿势和实现原理:一笔 SVG 动画到底是怎么画出来的。
项目地址:https://github.com/liuyueyi/quick-chinese-transfer 对应模块:
hanzi-writer(maven 坐标com.github.liuyueyi:hanzi-writer,当前最新版本0.1.1)
1. 能做什么
输入一个汉字,输出它的笔顺 SVG:
- 终态 SVG:一张静态图,包含田字格背景、字形灰色描边、笔画顺序编号;
- 逐笔 SVG 列表:每一笔一张图,当前笔高亮、前一笔过渡色、已完成的笔深色,适合做"一笔一笔教写字"的 UI;
- 书写动画 SVG:利用 SVG 的
stroke-dasharray+ CSS@keyframes,浏览器里直接播放按笔顺书写动画,无需任何 JS 库。
2. 快速上手
2.1 引入依赖
<!-- https://mvnrepository.com/artifact/com.github.liuyueyi/hanzi-writer -->
<dependency>
<groupId>com.github.liuyueyi</groupId>
<artifactId>hanzi-writer</artifactId>
<version>0.1.1</version>
</dependency>
gradle:
implementation 'com.github.liuyueyi:hanzi-writer:0.1.1'
零依赖!整个模块没有引入任何第三方库(连 json 解析库都没有,后面原理篇会讲为什么)。
2.2 最小示例
import com.github.liuyueyi.quick.hanzi.HanZiSvgGenerator;
import com.github.liuyueyi.quick.hanzi.module.HanZiRenderResultVo;
import com.github.liuyueyi.quick.hanzi.module.RenderStyleEnum;
import java.util.List;
public class HanZiWriterTest {
public static void main(String[] args) {
HanZiRenderResultVo vo = HanZiSvgGenerator
.newGenerator("帅")
.setRenderStyle(RenderStyleEnum.TOTAL)
.setStrokeNumShow(true) // 显示笔画顺序数字
.setAnimateShow(true) // 开启书写动画
.setOutlineShow(true) // 显示字形描边
.setWriteTipShow(true) // 显示书写轮廓提示
.setBgShow(true) // 显示田字格背景
.toSvgs();
// 带动画 + 描边 + 笔顺编号的完整 svg
String svg = vo.getSvg();
// 逐笔绘制的 svg 列表(每笔一张图)
List<String> strokeSvgList = vo.getStrokes();
}
}
把 svg 存成 .svg 文件用浏览器打开,就能看到一个会自己写字的"帅"字;strokeSvgList 里则是 5 张图,分别是写完第 1 笔、第 2 笔……第 5 笔的状态。
3. 参数详解
HanZiSvgGenerator 是典型的 Builder 模式,所有配置项及默认值如下:
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
size | int | 输出 svg 大小 | 300 |
svgStyle | string | svg 根标签的 style | border: 1px solid rgb(204,204,204); background-color: rgb(238,238,238); |
bgShow | boolean | 是否显示背景的田字格 | true |
bg | string | 田字格背景内容(自定义 svg 片段) | 灰色虚线田字格 |
outlineShow | boolean | 是否显示字形灰色描边 | true |
outlineColor | string | 描边颜色 | rgba(221,221,221,1) |
animateShow | boolean | 是否支持按笔顺动画展示 | true |
drawColor | string | 动画执行完之后的颜色 | rgb(85,85,85) |
midColor | string | 动画执行过程中的颜色 | rgb(68,68,255) |
animateColor | string | 动画开始执行的颜色 | rgb(187,68,68) |
animateSeconds | float | 每一笔动画时长(秒) | 0.9 |
writeTipShow | boolean | 是否显示书写轮廓提示 | true |
writeTipStyle | string | 轮廓提示样式 | .median-stroke{...} |
strokeNumShow | boolean | 是否显示笔顺数字 | true |
strokeNumStyle | string | 笔顺数字样式 | 白字黑描边的 Helvetica 50px |
renderStyle | enum | NORMAL / STROKE_ANIMATE / TOTAL | NORMAL |
注意:
svgStyle目前只对逐笔拆分的 svg(getStrokes())生效,终态 svg 的 style 是写死的默认值。
renderStyle 决定返回结果里有什么:
NORMAL:只返回vo.getSvg()(终态 svg,内部带动画定义);STROKE_ANIMATE:只返回vo.getStrokes()(逐笔 svg 列表,纯静态无动画);TOTAL:两个都返回。
4. 字库与数据格式
4.1 字库来源与覆盖
字库数据来自开源项目 chanind/hanzi-writer,处理成纯文本后打包在 jar 内:src/main/resources/hz/<汉字>.txt,共 9574 个文件(9534 个 CJK 基本区汉字 + 40 个扩展区汉字/部首符号),常用汉字覆盖无压力。
4.2 数据格式
每个汉字文件固定两行 JSON:
- 第 1 行:字形元信息(读音、释义、偏旁、部件拆解等),目前只做展示素材,不参与渲染;
- 第 2 行:渲染需要的核心数据,包含两个字段:
{
"character": "帅",
"strokes": [
"M 165 632 Q 166 626 169 620 ... Z", // 第 1 笔的 SVG path(字形轮廓)
"M 328 752 Q 367 689 351 450 ... Z", // 第 2 笔 ...
...
],
"medians": [
[[165,632],[169,620],[187,581],...], // 第 1 笔的"中线"采样点(x,y 坐标序列)
[[328,752],[367,689],[351,450],...],
...
]
}
两个数组按下标一一对应,各代表每笔的两面:
strokes:笔画的轮廓 path,决定"这一笔长什么样"(描边、填充用它);medians:笔画书写过程的中线轨迹点,决定"这一笔怎么写出来"(动画用它)。
理解了这组数据的语义,后面动画的原理就顺理成章了。
4.3 不支持的字符怎么办
字库中查不到对应字符时(jar 内无该字文件),会抛出 NullPointerException;文件存在但内容不足两行时返回 null。所以生产上记得对字库外的字符做兜底处理(比如降级渲染为文本,或提前用字符集过滤)。
5. 使用姿势扩展
5.1 逐笔教学效果
STROKE_ANIMATE / TOTAL 模式返回的逐笔 svg 是纯静态图,但内部做了状态着色:
- 当前正在写的这一笔 →
animateColor(红色高亮) - 前一笔 →
midColor(蓝色过渡) - 更早的笔画 →
drawColor(深灰,已写完)
在前端翻页或定时切换这些图片,就是最简单的"一笔一笔教写字"效果,不需要动画支持,适合嵌入 pdf、海报等静态介质。
5.2 自定义渲染内容
除了按字取字库,还支持把 HanZiRenderContents(character + strokes + medians)直接喂给生成器:
HanZiRenderContents content = ...; // 自行构造或解析
HanZiRenderResultVo vo = HanZiSvgGenerator.newGenerator(content)
.setRenderStyle(RenderStyleEnum.TOTAL)
.toSvgs();
这意味着你完全可以接入自己的字库数据(比如手写体、生僻字、艺术字),复用同一套 SVG 渲染管线。
6. 实现原理
整个模块一共 12 个类,核心链路是:
HanZiSvgGenerator.newGenerator("帅").toSvgs()
└──> FileReadUtil.readLines("hz/帅.txt") // 读字库第 2 行
└──> HanZiGenUtil.parseRenderContent(json) // 手写解析,得到 strokes + medians
└──> HanZiGenUtil.buildAniSvg(contents, options) // 生成 5 组 path/样式片段
└──> HanZiRender.toSvg / toStrokes // 拼装最终 svg 文本
6.1 不引入 json 库的解析(HanZiGenUtil)
数据是 JSON,但模块坚持零依赖,所以 parseRenderContent 用手写的"指针扫描"来解析:
character/strokes:定位字段名后的[],截取中间内容切分;medians:按[]配对维护一个level计数器,level==2时的,分隔 x/y,level==1的]收点、level==0的]收一笔,把嵌套数组逐层还原成List<List<Point>>。
这个解析只服务于结构固定的字库数据,牺牲一点通用性换来了零依赖和打包体积,思路值得借鉴:如果待解析的数据格式是你自己定的,手写解析往往比引库更划算。
6.2 笔画轮廓的组装
每笔的 strokes[i] 轮廓 path 直接输出两份:
- 一份进描边层
<g>,填充outlineColor,构成整个字的灰色底稿; - 一份包进
<clipPath id="clip-i">,作为第 i 笔动画的裁剪区域——这是动画效果的第二个关键点,见下节。
6.3 书写动画的原理
动画的核心是 SVG 的 dash 技巧:一条线用 stroke-dasharray = "L 2L"(L 为线段总长,实现里还额外加了 128 的余量防止衔接处缺口)把它切成一段实线一段空白,再用 stroke-dashoffset 从 L 动画到 0,视觉上就是"线从起点画到终点"。
对每个笔画 medians[i]:
用中线点串成折线 path(
M x y L x y ...),并计算折线总长dashArray;输出动画 path,挂上
clip-path="url(#clip-i)"——动画只能在第 i 笔的轮廓内渲染,这样粗线条就不会画出格子;<clipPath id="clip-0"><path d="...第0笔轮廓..."></path></clipPath> <path clip-path="url(#clip-0)" d="...第0笔中线..." fill="none" stroke-dasharray="L L*2" stroke-linecap="round" id="animation-0" class="ani-tag animated"></path>每一笔生成一组
@keyframes,三段式颜色 + 线宽动画:@keyframes keyframes0 { from { stroke: animateColor; stroke-dashoffset: L; stroke-width: 128; } 60% { stroke: midColor; stroke-dashoffset: 0; stroke-width: 128; } to { stroke: drawColor; stroke-width: 1024; } } #animation-0.animated { animation: keyframes0 0.9s both; animation-delay: 0s; /* 第 i 笔延迟 i * animateSeconds 秒 */ }from → 60%:dashoffset 从 L 到 0,笔画"被写出来",颜色从高亮红过渡到蓝色;60% → to:stroke-width 从 128 撑到 1024,配合 clipPath,粗线恰好把整个轮廓填满,完成"上色";
animation-delay: i * animateSeconds让各笔按顺序接力播放,就得到了完整的书写动画。
6.4 笔顺数字与书写提示
- 笔顺数字:取每笔中线第一个点作为定位点,输出
<text>;由于整体坐标系做了scale(1,-1)翻转(SVG 的 y 轴朝下,而字库数据 y 轴朝上),text 上再套一层transform: scale(1,-1)把数字翻转回来; - 书写提示:把每笔的中线折线用
.median-stroke样式(半透明细线)画出来,即写字时参考的"行笔路线"。
6.5 两套坐标系的翻转
整个 svg 输出时套了一层 <g transform="scale(1,-1) translate(0,-900)"> 把 y 轴翻转回来,所以内部所有 path 的坐标都可以直接使用字库里的原始数据,不需要逐点换算——这是一个"翻转容器,而不是翻转数据"的实用技巧。
7. 小结
hanzi-writer的本质:字库数据(轮廓 + 中线)+ SVG 组装器,零依赖、开箱即用;- 动画三件套:
strokes轮廓做 clipPath,medians中线做书写轨迹,dash 技巧做"描画"动效; renderStyle三种模式覆盖三种场景:终态展示(NORMAL)、逐笔教学图(STROKE_ANIMATE)、动图/网页动画(TOTAL + animateShow)。
如果再往前追一层,medians 中线数据来自 hanzi-writer-data 的笔画书写轨迹采集,轮廓来自字形矢量化,感兴趣的同学可以去 hanzi-writer 项目围观。