2. 汉字笔顺生成(hanzi-writer)

一灰灰blogQuick系列QuickChineseTransfer约 2519 字大约 8 分钟

quick-chinese-transfer 仓库里除了简繁转换的 transfer-core,还藏了一个好玩的模块:hanzi-writer,它能把一个汉字拆成一笔一画,输出带书写动画的 SVG。基于它做的「查笔顺」APP 已经上架 GooglePlay,也提供了在线体验版open in new window。

这篇教程介绍它的使用姿势和实现原理:一笔 SVG 动画到底是怎么画出来的。

项目地址:https://github.com/liuyueyi/quick-chinese-transferopen in new window 对应模块: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 模式,所有配置项及默认值如下:

参数类型说明默认值
sizeint输出 svg 大小300
svgStylestringsvg 根标签的 styleborder: 1px solid rgb(204,204,204); background-color: rgb(238,238,238);
bgShowboolean是否显示背景的田字格true
bgstring田字格背景内容(自定义 svg 片段)灰色虚线田字格
outlineShowboolean是否显示字形灰色描边true
outlineColorstring描边颜色rgba(221,221,221,1)
animateShowboolean是否支持按笔顺动画展示true
drawColorstring动画执行完之后的颜色rgb(85,85,85)
midColorstring动画执行过程中的颜色rgb(68,68,255)
animateColorstring动画开始执行的颜色rgb(187,68,68)
animateSecondsfloat每一笔动画时长(秒)0.9
writeTipShowboolean是否显示书写轮廓提示true
writeTipStylestring轮廓提示样式.median-stroke{...}
strokeNumShowboolean是否显示笔顺数字true
strokeNumStylestring笔顺数字样式白字黑描边的 Helvetica 50px
renderStyleenumNORMAL / STROKE_ANIMATE / TOTALNORMAL

注意:svgStyle 目前只对逐笔拆分的 svg(getStrokes())生效,终态 svg 的 style 是写死的默认值。

renderStyle 决定返回结果里有什么:

  • NORMAL:只返回 vo.getSvg()(终态 svg,内部带动画定义);
  • STROKE_ANIMATE:只返回 vo.getStrokes()(逐笔 svg 列表,纯静态无动画);
  • TOTAL:两个都返回。

4. 字库与数据格式

4.1 字库来源与覆盖

字库数据来自开源项目 chanind/hanzi-writeropen in new window,处理成纯文本后打包在 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]:

  1. 用中线点串成折线 path(M x y L x y ...),并计算折线总长 dashArray;

  2. 输出动画 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>
    
  3. 每一笔生成一组 @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,粗线恰好把整个轮廓填满,完成"上色";
  4. 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-writeropen in new window 项目围观。

相关链接

Loading...