使用程序绘制中文字体——中文字体的参数化设计方案初探

23 阅读25分钟

使用程序绘制中文字体——中文字体的参数化设计方案初探

写在前面

笔者一直非常羡慕写字画画很好的朋友,但是自己小时候没有培养起来这方面的特长,长大后接触编程,就“异想天开”能不能使用程序帮助自己写字画画。上学的时候,笔者在使用程序“参数化绘画”方向做了很多“闭门造车”式的尝试,最后效果都不太理想。步入社会后一直没有忘记当初的构想,但是使用程序绘画,是个极其复杂的工程,里面涉及参数过多,很难精简,在用户体验和图画效果的权衡上很难做取舍。偶然接触字体和图标设计领域,由最初的好奇到对行业前沿有一定的了解,发现这个领域的一些前沿应用与自己的构想不谋而合,比如可变字体标准的推出等等。诚然,相比与图画,字体笔画的设计更加精简,数字化更加流行,尤其对中文字体,在参数化、数字化字形方向还有很大的研究空间。于是笔者就将自己的精力专注于中文字体设计的参数化设计方向,基于这个初心,做了一款目前尚且简陋的字体设计工具——《字玩》。

字玩开源地址:gitee | github

字玩介绍:字玩官网

使用自己制作的工具字玩,笔者初步尝试了实现程序绘制笔画,再制作可视化的界面让用户可以将笔画“组装”成字形,并可以通过拖拽笔画调整骨架:

1.gif

上图中绘制的“永”字笔画设计参考的黑体,之所以选择黑体作为入门,是因为黑体笔画构架相对简单,适合使用程序绘制并做参数化设计。其实,笔者最初的尝试是使用绘制“隶书”风格的笔画,比如,笔者实用程序的方式绘制了隶书“蚕头燕尾”的横:

2.jpeg

但是,隶书笔画没有黑体那样规整,就造成了很多问题:一是参数过多且不易理解,比如隶书蚕头的绘制就囊括了左切角、上切角、下切角等多个参数,二是在动态调整期间,绘制效果不尽如人意,比如在长度为500时蚕头燕尾还是正常的,但是缩短长度到100就没有办法看了。

思来想去,笔者最终决定暂时搁置隶书的程序化进程,尝试使用黑体作为入门。通过一段时间的努力,笔者使用程序绘制了黑体常用的32个笔画,并基于“笔画组装字形”的设想,参考思源黑体的结构,制作了《登鹳雀楼》一诗中包含的20个中文字形:

3.jpeg

在上述图片中,20个中文字形的笔画全部由程序进行绘制,在字玩中通过调参将笔画组合成字形,效果基本可以达到预期。这篇博客就简单介绍一下在程序化、参数化绘制黑体字形时,作出的设计和遇到的问题。

基本设计思路

笔者将探索过程中的设计思路抽象成简单两点:

  1. 笔画->字形:由笔画经过调参,组合成字形
  2. 骨架+风格->笔画:对于单个笔画的绘制,抽象出骨架参数和风格参数。骨架参数由尽量精简的参数确定笔画骨架,在确定骨架的基础上,由风格参数控制笔画风格的调整

字形,顾名思义,即字体呈现出的最终轮廓形状。相比于用钢笔工具一笔笔勾勒草图上的轮廓数据,越来越多的设计师选择用“部件”的方式,将笔画作为最小单元,“组合拼装”成字形。笔者采纳了这种设计方式:通过复用仅仅32个常用笔画,就可以拼装成7000个不同的汉字,这将极大的提高设计效率。

确定了由笔画组装成字形的设计思路,如何设计笔画的参数就变得至关重要。相比于“好看”,我们的首要目的是通过调参的方式,可以由32个常用笔画“拟合”出任意汉字,并且让参数尽可能精简、易懂。其次,才是如何将笔画绘制得好看。基于这一点,笔者将参数分为两类:第一类参数是骨架参数,用于绘制笔画骨架、结构,这类参数要尽量精简,易懂。第二类参数为风格参数,用于绘制笔画的样式、风格,这类参数可以不断扩展,使用同一个骨架,可以绘制无数个不同的风格。这样,就抽象出两层参数组:骨架参数和风格参数。打个比方,黑体、宋体、楷体三个风格的字体,完全可以使用同一组骨架,仅仅改变风格参数,就可以使同样结构的字体变为不同的风格。

笔画骨架参数设计

确定了基本设计思路,接下来就是最重要也是最基本的环节:设计笔画骨架以及其参数。

听到“骨架”二字,读者或许会有疑惑所谓“骨架”到底是什么?是一组贝塞尔曲线?还是一组线段?这些好像都可以作为骨架。但是字玩中,笔者选择将骨架数据由一组关键点表示。只要给出关键点,程序就可以根据这组关键点生成相应笔画的基础轮廓。

打个简单的比方,笔画“横”的骨架数据为:

const skeleton = {
  start: { x, y },
  end: { x, y },
}

骨架数据给出了横之骨架的起点和终点,接下来在绘制的时候,程序就会根据起点和终点绘制横的轮廓,比如:

pen.moveTo(skeleton.start.x, skeleton.start.y)
pen.lineTo(skeleton.end.y, skeleton.end.y)

当然,上述代码只是做一个简单的示例,最终的绘制代码肯定比上述代码要复杂很多。

这时候读者可能又会疑惑:那么,骨架的关键点要怎么生成呢?难道需要让用户手动点击屏幕设置关键点么?尽管字玩确实实现了拖拽改变关键点位置的功能,但是直观上关键点肯定不是直接面向用户的参数。直接面向用户的,也就是需要让用户设置的,是更直观的基本参数。比如对于笔画“横”,抽象出来的骨架参数只有“长度”一个,只要有了“长度”这个参数,程序会自动计算相应关键点。读者可能会问,参数中没有实际坐标,怎么能确定关键点呢?事实上,在默认情况下,笔画会被画在屏幕中央,当用户引用某个笔画组件时,可以以拖拽或输入的方式手动改变笔画的位置(ox, oy),这时候关键点位置会被重新计算,笔画也会随之移动。

综上所述,我们目前的任务是设计绘制每个笔画所需的骨架关键点,以及通过这些关键点抽象出来更直观的基本参数供用户调整。

在进行关键点和基本参数设计之前,我们先小小研究一下笔画特征:虽然笔者整理出的常用笔画总共有32个,但是这些笔画中,基础笔画仅有“横竖撇捺折点挑”七个,其他大多都是由基础笔画拼成的“复合笔画”。比如下图中的“横折钩”笔画,就可以拆解成“横”、“折”、“钩”三个基础笔画,而其中“折”和“钩”两个笔画其实都可以算作笔画“折”。所谓“折”的骨架,就是可以旋转任意角度而没有弯曲的线段,与“横”、“竖”的区别仅仅是多了角度的旋转。以此类推,全部32个笔画都可以使用“横竖撇捺折点挑”这七个笔画组成,所以我们的首要任务就是设计这7个基础笔画的参数。

问题到了现在变得简单多了。

首先,对于“横”和“竖”的骨架设计比较直观,不考虑角度问题,也就是默认只能“横平竖直”的情况下,“横”、“竖”的骨架关键点可以由两个端点构成。而在知道笔画组件位置(ox, oy)的情况下,可以提取出“长度”这个比较直观的参数。

对于“折”,也就是可以旋转任意角度的线段,关键点也可以由两个端点确定。至于面向用户的基本参数提取上,可能最直观的是使用“长度”+“角度”的方式。但是笔者在字玩中没有采用这种设计,而是将参数设计为“水平延伸”和“竖直延伸”两个。所谓水平延伸,即两个端点间的水平距离,而竖直延伸,则为两个端点的竖直距离。水平延伸和竖直延伸都可以设置为负数,通过正负区分折的方向。

为什么采用这种设计呢?主要是为了比较进阶的功能“参数与布局绑定“而考量的——在字玩中,用户可以对组件设置布局,并将笔画参数与布局参数进行绑定,以达到改变布局便能改变笔画骨架的效果。打个最常见的比方,对于部首“木字旁”,在不同的汉字中,占宽可能不尽相同,比如“林”和“树”两个字,尽管都要使用“木字旁”,但是“树”中的“木字旁”明显要更窄一些。这时候,在复用组件时,我们可以为“木字旁”添加一个最简单的矩形布局,由“长”“宽”两个参数确定布局。为了实现改变布局(比如调整“木”的宽度)时笔画被重新绘制以适应新布局,我们可以将布局参数与笔画骨架参数进行绑定。这时候,如果使用“长度”+“角度”的参数设计,很难直观上与宽高对应——如果将笔画“撇”的“长度”与部首组件“木”的“宽度”参数绑定,在改变宽度时,很可能会出现撇长度过长或错位的效果,而我们仅仅希望在“木字旁”变宽一些的时候,撇可以在不被压缩字重的情况下,水平延伸更大一些以占满布局。所以对于笔画“折”,笔者最终采用“水平延伸”和“竖直延伸”两个参数以确定关键点。

4.gif

对于“撇”、“捺”、“点”、“挑”四个笔画,与“折”的区别就是在角度的基础上,增加了弯曲度。所以笔者沿用“水平延伸”和“竖直延伸”两个参数来确定“撇”、“捺”、“点”、“挑”的起点与终点,并在此基础上,增加一个拐点来确定弯曲度。“撇”、“捺”、“点”、“挑”的骨架关键点相同,都是由起点、终点、拐点三个关键点构成的集合,而最终的笔画骨架成像,就是以两个端点为锚点,并以拐点为控制点的二次贝塞尔曲线。为了确定拐点的位置,笔者增加了两个面向用户的参数:弯曲度和弯曲游标。弯曲度确定了拐点到两个端点连线的垂直距离;而弯曲游标确定了拐点到两个端点作垂线的垂足位置。

5.gif

最终,全部笔画生成如下:

6.jpeg

笔画风格参数设计

有了笔画骨架,也就可以生成笔画的基本轮廓,但是这样笔画毕竟略显单调,要是希望笔画变得再好看一些就需要进行风格参数的设计。除了字重之外,目前字玩仅仅做了用于测试的四种风格参数支持,设计上还比较简单,仅供参考:

起笔风格

目前支持三种起笔风格:默认样式,凸笔起笔,凸笔圆角起笔,样式分别为下图所示。

7.gif

对于每种起笔样式,还增设了一个“起笔数值”的数值参数控制对应风格的细节调整。比如对于凸笔圆角起笔,在改变“起笔数值”参数的情况下,样式会对应发生改变。

绘制起笔的代码示例:

if (start_style_type === 0) {
  // 无起笔样式
  pen.moveTo(out_heng_start.x, out_heng_start.y)
} else if (start_style_type === 1) {
  // 起笔上下凸起长方形
  pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y)
} else if (start_style_type === 2) {
  // 起笔上下凸起长方形,长方形内侧转角为圆角
  pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
  pen.quadraticBezierTo(
    out_heng_start.x + start_style.start_style_decorator_width,
    out_heng_start.y,
    out_heng_start.x + start_style.start_style_decorator_width + start_style.start_style_decorator_radius,
    out_heng_start.y,
  )
}
转角风格

目前支持两种转角风格:默认样式,转角圆滑凸起,样式分别为下图所示。

8.gif

对于每种起笔样式,还增设了一个“转角数值”的数值参数控制对应风格的细节调整。比如对于转角圆滑凸起,在改变“转角数值”参数的情况下,样式会对应发生改变。

绘制转角的代码示例:

if (bending_degree > 1 && turn_style_type === 0) {
  // 绘制外侧横折圆角
  pen.lineTo(out_radius_start_heng_zhe.x, out_radius_start_heng_zhe.y)
  pen.quadraticBezierTo(out_corner_heng_zhe.x, out_corner_heng_zhe.y, out_radius_end_heng_zhe.x, out_radius_end_heng_zhe.y)
} else if (turn_style_type === 0) {
  pen.lineTo(out_corner_heng_zhe_up.x, out_corner_heng_zhe_up.y)
  pen.lineTo(out_corner_heng_zhe_down.x, out_corner_heng_zhe_down.y)
} else if (turn_style_type === 1) {
  // 转角样式1
  pen.lineTo(turn_data.turn_start_1.x, turn_data.turn_start_1.y)
  pen.quadraticBezierTo(turn_data.turn_control_1.x, turn_data.turn_control_1.y, turn_data.turn_end_1.x, turn_data.turn_end_1.y)
  pen.lineTo(turn_data.turn_end_2.x, turn_data.turn_end_2.y)
  pen.quadraticBezierTo(turn_data.turn_control_2.x, turn_data.turn_control_2.y, turn_data.turn_start_2.x, turn_data.turn_start_2.y)
}
字重变化

字重变化为单个数值参数,表示笔画字重由起笔到收笔的变化程度,目前仅支持改变撇、捺的字重变化。效果如下:

9.gif

弯曲程度

弯曲程度为单个数值参数,可以影响撇和捺弯曲程度,同时在默认转角样式下,可以影响转角弯曲程度。效果如下:

10.gif

字形的“组装拟合”

设计好笔画参数,字形的“组装拟合”看起来是个简单的步骤,但这其中还是会遇到不少细节问题。

我们首先来看一下一个简单的“口”字的组装:

组装时,为了使笔画连接处闭合,我们很直观地将“竖”和“横折”贴靠在了一起,但是当改变字重时,我们会发现,笔画错位了:

11.gif

这是由于什么造成的呢?原来在默认情况下,“竖”的骨架关键点处于笔画两侧轮廓的正中央,在改变字重时,“竖”增宽了,这会导致“竖”最左侧的轮廓向左移动,而“横折”的起笔在字重变化时没有发生改变,就导致了错位现象。

为了解决这个问题,笔者增加了一个参数“参考位置”,指代笔画骨架的固定参考位置为右侧(上侧)、左侧(下侧)还是默认的处于中间。我们将“口”字的笔画“竖”之参考位置改为左侧(下侧),也就是骨架固定在笔画最左侧,当改变字重时,左侧轮廓位置是不变的。同时,将“横折”的参考位置改变为右侧(上侧)。再次改变字重,错位问题就解决了:

12.gif

解决了改变字重之后的错位问题,其实还有一个重要问题没有解决——自动吸附对齐。我们在设计时,经常需要让笔画连接处闭合对齐,这在成熟设计工具中可以使用吸附对齐的方式帮助用户快速调节。但是目前在字玩中,还没有做这方面的工作,需要用户手动对齐笔画,其实是比较麻烦的。这也作为未来的一项待办任务。

基于骨架的结构调整

在已知字体轮廓的情况下,我们可以使用程序对其做出结构上的调整,比较常见的调整方式为调整中宫和重心。中宫,即将字形绘制在九宫格中时,中心格所占的大小。调整中心格的大小可以改变字形的紧收程度。而重心则表示字形“重量感”的集中点。

字玩中支持用户使用直观拖拽九宫格的方式调整任意字形轮廓的中宫与重心。但是,在默认情况下,调整中宫使中宫变大时,会使越靠近中心的轮廓字重变得宽厚,靠近边缘的轮廓字重变得窄小:

13.gif

这是由于改变中宫后,重新计算各个轮廓点的新坐标时,中心格被放大,其中的点阵也按比例扩大而造成的。

为了解决这一问题,对于使用了骨架作为基本数据的笔画,字玩支持“基于骨架的调整”方式:在调整中宫时,仅重新计算骨架关键点的位置,然后根据新的骨架关键点生成字形。效果如下:

14.gif

由于骨架是没有字重的连线,所以改变骨架关键点不会使轮廓“扩张”。这样,问题就得到圆满解决。

笔画绘制二三事

讲到现在,读者可能会觉得烦躁:说了这么多概念,到底绘制笔画的程序代码在哪?这一节我们将以笔画“横”的脚本代码作为示例,仔细聊聊如何使用程序绘制笔画,以及其中遇到的问题和解决方案。

需要绘制笔画,首先我们需要读取参数,在字玩中,我们可以使用API glyph.getParam('paramName') 来读取指定参数:

const params = {
  length: glyph.getParam('长度'),
  skeletonRefPos: glyph.getParam('参考位置'),
}
const global_params = {
  weights_variation_power: glyph.getParam('字重变化'),
  start_style_type: glyph.getParam('起笔风格'),
  start_style_value: glyph.getParam('起笔数值'),
  turn_style_type: glyph.getParam('转角风格'),
  turn_style_value: glyph.getParam('转角数值'),
  bending_degree: glyph.getParam('弯曲程度'),
  weight: glyph.getParam('字重') || 40,
}

接下来,我们需要根据这些参数生成用于绘制形状的关键点。最基本的关键点自然是骨架关键点:

const { length, skeletonRefPos } = params
const { weight } = global_params

let start, end
const start_ref = new FP.Joint(
  'start_ref',
  {
    x: x0,
    y: y0,
  },
)
const end_ref = new FP.Joint(
  'end_ref',
  {
    x: start_ref.x + length,
    y: start_ref.y,
  },
)
if (skeletonRefPos === 1) {
  // 骨架参考位置为右侧(上侧)
  start = new FP.Joint(
    'start',
    {
      x: start_ref.x,
      y: start_ref.y + weight / 2,
    },
  )
  end = new FP.Joint(
    'end',
    {
      x: end_ref.x,
      y: end_ref.y + weight / 2,
    },
  )
} else if (skeletonRefPos === 2) {
  // 骨架参考位置为左侧(下侧)
  start = new FP.Joint(
    'start',
    {
      x: start_ref.x,
      y: start_ref.y - weight / 2,
    },
  )
  end = new FP.Joint(
    'end',
    {
      x: end_ref.x,
      y: end_ref.y - weight / 2,
    },
  )
} else {
  // 默认骨架参考位置,即骨架参考位置为中间实际绘制的骨架位置
  start = new FP.Joint(
    'start',
    {
      x: start_ref.x,
      y: start_ref.y,
    },
  )
  end = new FP.Joint(
    'end',
    {
      x: end_ref.x,
      y: end_ref.y,
    },
  )
}
glyph.addJoint(start_ref)
glyph.addJoint(end_ref)
glyph.addRefLine(refline(start_ref, end_ref, 'ref'))

glyph.addJoint(start)
glyph.addJoint(end)

const skeleton = {
  start,
  end,
}

glyph.addRefLine(refline(start, end))

当看到如下代码:

const skeleton = {
  start,
  end,
}

我们可以很清晰地看出,笔画“横”的骨架关键点仅包含两个端点:起点和终点。但是有些读者可能会觉得上述代码有些复杂,两个端点的坐标计算在不同情况下是不一样的,这是为什么呢?在“由笔画组装字形”一节中,我们已经了解到“参考位置”的概念,也就是当用户改变字重时,骨架固定在笔画中间,还是固定在上侧或下侧。允许用户手动设置“参考位置”,可以有效解决调整字重时笔画连接处错位的问题。所以,我们要先生成参考骨架start_refend_ref,然后根据参考位置的不同,计算最终用于生成笔画轮廓的“中间骨架”。

有了骨架数据,我们就可以基于骨架绘制笔画了。首先,我们要根据骨架计算绘制形状所需的其他关键点:

// 根据骨架计算轮廓关键点
const { start, end } = skeleton

// out指上侧(外侧)轮廓线
// in指下侧(内侧)轮廓线
const { out_heng_start, out_heng_end, in_heng_start, in_heng_end } = FP.getLineContours('heng', { heng_start: start, heng_end: end }, weight)

可以看到,我们计算出了四个关键点:out_heng_start, out_heng_end, in_heng_start, in_heng_end,它们分别为笔画横的上侧轮廓起始点和终点,以及下侧轮廓起始点和终点。

有了这些关键点,我们就可以开始绘制笔画形状了。对于笔画“横”,我们可以调节笔画起始风格,所以,针对不同风格,绘制代码会有一些小小的不同:

// 创建钢笔组件
const pen = new FP.PenComponent()
pen.beginPath()

// 绘制横的上侧轮廓
if (start_style_type === 0) {
  // 无起笔样式
  pen.moveTo(out_heng_start.x, out_heng_start.y)
} else if (start_style_type === 1) {
  // 起笔上下凸起长方形
  pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y)
} else if (start_style_type === 2) {
  // 起笔上下凸起长方形,长方形内侧转角为圆角
  pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
  pen.quadraticBezierTo(
    out_heng_start.x + start_style.start_style_decorator_width,
    out_heng_start.y,
    out_heng_start.x + start_style.start_style_decorator_width + start_style.start_style_decorator_radius,
    out_heng_start.y,
  )
}
pen.lineTo(out_heng_end.x, out_heng_end.y)

// 绘制轮廓连接线
pen.lineTo(in_heng_end.x, in_heng_end.y)

// 绘制横的下侧轮廓
if (start_style_type === 0) {
  // 无起笔样式
  pen.lineTo(in_heng_start.x, in_heng_start.y)
} else if (start_style_type === 1) {
  // 起笔上下凸起长方形
  pen.lineTo(in_heng_start.x + start_style.start_style_decorator_width, in_heng_start.y)
  pen.lineTo(in_heng_start.x + start_style.start_style_decorator_width, in_heng_start.y + start_style.start_style_decorator_height)
  pen.lineTo(in_heng_start.x, in_heng_start.y + start_style.start_style_decorator_height)
} else if (start_style_type === 2) {
  // 起笔上下凸起长方形,长方形内侧转角为圆角
  pen.lineTo(
    in_heng_start.x + start_style.start_style_decorator_width + start_style.start_style_decorator_radius,
    in_heng_start.y,
  )
  pen.quadraticBezierTo(
    in_heng_start.x + start_style.start_style_decorator_width,
    in_heng_start.y,
    in_heng_start.x + start_style.start_style_decorator_width,
    in_heng_start.y + start_style.start_style_decorator_height,
  )
  pen.lineTo(in_heng_start.x, in_heng_start.y + start_style.start_style_decorator_height)
}

// 绘制轮廓连接线
if (start_style_type === 0) {
  // 无起笔样式
  pen.lineTo(out_heng_start.x, out_heng_start.y)
} else if (start_style_type === 1) {
  // 起笔上下凸起长方形
  pen.lineTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
} else if (start_style_type === 2) {
  // 起笔上下凸起长方形,长方形内侧转角为圆角
  pen.lineTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
}

pen.closePath()

代码中,我们使用了字玩内置API FP.PenComponent 创建了一个钢笔组件,并根据起始风格的不同,绘制出了不同风格的笔画横。

为了讲解方便,笔者省略了脚本中的一些辅助代码,笔画“横”的完整代码如下:

const ox = 500
const oy = 500
const x0 = 250
const y0 = 500
const params = {
  length: glyph.getParam('长度'),
  skeletonRefPos: glyph.getParam('参考位置'),
}
const global_params = {
  weights_variation_power: glyph.getParam('字重变化'),
  start_style_type: glyph.getParam('起笔风格'),
  start_style_value: glyph.getParam('起笔数值'),
  turn_style_type: glyph.getParam('转角风格'),
  turn_style_value: glyph.getParam('转角数值'),
  bending_degree: glyph.getParam('弯曲程度'),
  weight: glyph.getParam('字重') || 40,
}

const getJointsMap = (data) => {
  const { draggingJoint, deltaX, deltaY } = data
  const jointsMap = Object.assign({}, glyph.tempData)
  switch (draggingJoint.name) {
    case 'end': {
      jointsMap['end'] = {
        x: glyph.tempData['end'].x + deltaX,
        y: glyph.tempData['end'].y,
      }
      break
    }
  }
  return jointsMap
}

glyph.onSkeletonDragStart = (data) => {
  // joint数据格式:{x, y, name}
  const { draggingJoint } = data
  glyph.tempData = {}
  glyph.getJoints().map((joint) => {
    const _joint = {
      name: joint.name,
      x: joint.x,
      y: joint.y,
    }
    glyph.tempData[_joint.name] = _joint
  })
}

glyph.onSkeletonDrag = (data) => {
  if (!glyph.tempData) return
  glyph.clear()
  // joint数据格式:{x, y, name}
  const jointsMap = getJointsMap(data)
  const _params = computeParamsByJoints(jointsMap)
  updateGlyphByParams(_params, global_params)
}

glyph.onSkeletonDragEnd = (data) => {
  if (!glyph.tempData) return
  glyph.clear()
  // joint数据格式:{x, y, name}
  const jointsMap = getJointsMap(data)
  const _params = computeParamsByJoints(jointsMap)
  updateGlyphByParams(_params, global_params)
  glyph.setParam('长度', _params.length)
  glyph.tempData = null
}

const range = (value, range) => {
  if (value < range.min) {
    return range.min
  } else if (value > range.max) {
    return range.max
  }
  return value
}

const computeParamsByJoints = (jointsMap) => {
  const { start, end } = jointsMap
  const length_range = glyph.getParamRange('长度')
  const length = range(end.x - start.x, length_range)
  return {
    length,
    skeletonRefPos: glyph.getParam('参考位置'),
  }
}

const refline = (p1, p2, type) => {
  const refline =  {
    name: `${p1.name}-${p2.name}`,
    start: p1.name,
    end: p2.name,
  }
  if (type) {
    refline.type = type
  }
  return refline
}

const updateGlyphByParams = (params, global_params) => {
  const { length, skeletonRefPos } = params
  const { weight } = global_params

  let start, end
  const start_ref = new FP.Joint(
    'start_ref',
    {
      x: x0,
      y: y0,
    },
  )
  const end_ref = new FP.Joint(
    'end_ref',
    {
      x: start_ref.x + length,
      y: start_ref.y,
    },
  )
  if (skeletonRefPos === 1) {
    // 骨架参考位置为右侧(上侧)
    start = new FP.Joint(
      'start',
      {
        x: start_ref.x,
        y: start_ref.y + weight / 2,
      },
    )
    end = new FP.Joint(
      'end',
      {
        x: end_ref.x,
        y: end_ref.y + weight / 2,
      },
    )
  } else if (skeletonRefPos === 2) {
    // 骨架参考位置为左侧(下侧)
    start = new FP.Joint(
      'start',
      {
        x: start_ref.x,
        y: start_ref.y - weight / 2,
      },
    )
    end = new FP.Joint(
      'end',
      {
        x: end_ref.x,
        y: end_ref.y - weight / 2,
      },
    )
  } else {
    // 默认骨架参考位置,即骨架参考位置为中间实际绘制的骨架位置
    start = new FP.Joint(
      'start',
      {
        x: start_ref.x,
        y: start_ref.y,
      },
    )
    end = new FP.Joint(
      'end',
      {
        x: end_ref.x,
        y: end_ref.y,
      },
    )
  }
  glyph.addJoint(start_ref)
  glyph.addJoint(end_ref)
  glyph.addRefLine(refline(start_ref, end_ref, 'ref'))
  
  glyph.addJoint(start)
  glyph.addJoint(end)

  const skeleton = {
    start,
    end,
  }
  
  glyph.addRefLine(refline(start, end))

  const components = getComponents(skeleton, global_params)
  for (let i = 0; i < components.length; i++) {
    glyph.addComponent(components[i])
  }

  glyph.getSkeleton = () => {
    return skeleton
  }
  glyph.getComponentsBySkeleton = (skeleton) => {
    return getComponents(skeleton, global_params)
  }
}

const getComponents = (skeleton, global_params) => {
  // 获取骨架以外的全局风格变量
  const { start_style_type, start_style_value, weight } = global_params

  const getStartStyle = (start_style_type, start_style_value) => {
    if (start_style_type === 1) {
      // 起笔上下凸起长方形
      return {
        start_style_decorator_width: start_style_value * 20,
        start_style_decorator_height: weight * 0.25,
      }
    } else if (start_style_type === 2) {
      // 起笔上下凸起长方形,长方形内侧转角为圆角
      return {
        start_style_decorator_width: start_style_value * 20,
        start_style_decorator_height: weight * 0.25,
        start_style_decorator_radius: 20,
      }
    }
    return {}
  }

  const start_style = getStartStyle(start_style_type, start_style_value)

  // 根据骨架计算轮廓关键点
  const { start, end } = skeleton

  // out指上侧(外侧)轮廓线
  // in指下侧(内侧)轮廓线
  const { out_heng_start, out_heng_end, in_heng_start, in_heng_end } = FP.getLineContours('heng', { heng_start: start, heng_end: end }, weight)

  // 创建钢笔组件
  const pen = new FP.PenComponent()
  pen.beginPath()

  // 绘制横的上侧轮廓
  if (start_style_type === 0) {
    // 无起笔样式
    pen.moveTo(out_heng_start.x, out_heng_start.y)
  } else if (start_style_type === 1) {
    // 起笔上下凸起长方形
    pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
    pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
    pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y)
  } else if (start_style_type === 2) {
    // 起笔上下凸起长方形,长方形内侧转角为圆角
    pen.moveTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
    pen.lineTo(out_heng_start.x + start_style.start_style_decorator_width, out_heng_start.y - start_style.start_style_decorator_height)
    pen.quadraticBezierTo(
      out_heng_start.x + start_style.start_style_decorator_width,
      out_heng_start.y,
      out_heng_start.x + start_style.start_style_decorator_width + start_style.start_style_decorator_radius,
      out_heng_start.y,
    )
  }
  pen.lineTo(out_heng_end.x, out_heng_end.y)

  // 绘制轮廓连接线
  pen.lineTo(in_heng_end.x, in_heng_end.y)

  // 绘制横的下侧轮廓
  if (start_style_type === 0) {
    // 无起笔样式
    pen.lineTo(in_heng_start.x, in_heng_start.y)
  } else if (start_style_type === 1) {
    // 起笔上下凸起长方形
    pen.lineTo(in_heng_start.x + start_style.start_style_decorator_width, in_heng_start.y)
    pen.lineTo(in_heng_start.x + start_style.start_style_decorator_width, in_heng_start.y + start_style.start_style_decorator_height)
    pen.lineTo(in_heng_start.x, in_heng_start.y + start_style.start_style_decorator_height)
  } else if (start_style_type === 2) {
    // 起笔上下凸起长方形,长方形内侧转角为圆角
    pen.lineTo(
      in_heng_start.x + start_style.start_style_decorator_width + start_style.start_style_decorator_radius,
      in_heng_start.y,
    )
    pen.quadraticBezierTo(
      in_heng_start.x + start_style.start_style_decorator_width,
      in_heng_start.y,
      in_heng_start.x + start_style.start_style_decorator_width,
      in_heng_start.y + start_style.start_style_decorator_height,
    )
    pen.lineTo(in_heng_start.x, in_heng_start.y + start_style.start_style_decorator_height)
  }

  // 绘制轮廓连接线
  if (start_style_type === 0) {
    // 无起笔样式
    pen.lineTo(out_heng_start.x, out_heng_start.y)
  } else if (start_style_type === 1) {
    // 起笔上下凸起长方形
    pen.lineTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  } else if (start_style_type === 2) {
    // 起笔上下凸起长方形,长方形内侧转角为圆角
    pen.lineTo(out_heng_start.x, out_heng_start.y - start_style.start_style_decorator_height)
  }

  pen.closePath()
  return [ pen ]
}

updateGlyphByParams(params, global_params)

了解了笔画横的绘制过程,你是不是觉得使用程序绘制笔画非常简单?但其实在绘制过程中,我们还会遇到各种各样的细节问题,接下来笔者将简述一些绘制中遇到的细节问题。

撇的两侧轮廓绘制——不是两条贝塞尔曲线那么简单

在绘制笔画“撇”的时候,要绘制出字重的厚度需要绘制骨架两侧的轮廓。最直观的方式是将骨架关键点左移 weight / 2 作为左侧轮廓关键点,并右移 weight / 2 作为右侧轮廓关键点,再以轮廓关起点终点为锚点,拐点为控制点绘制成二次贝塞尔曲线。然后这样绘制出的两条轮廓,其实不是每个截段字重都相同,并且字重也并非每处都是 weight 不变。要解决这个问题,我们需要提取在骨架二次贝塞尔曲线上的离散点,比如,从起点到终点均匀提取100个离散点。然后,计算每个离散点在曲线上的切线,根据切线位置垂直移动 weight / 2 作为新轮廓上的离散点。最后,连新轮廓上的离散点拟合为新的贝塞尔曲线。这样,问题就圆满解决了。

转角的处理,怎样能显得不突兀?

一般来说,在绘制轮廓时,笔者会先根据骨架关键点生成外轮廓与内轮廓,然而当绘制转角时,直接使用两个衔接笔画的终点与起点是不行的,因为它们是错位的。比较直观的方式是分别计算两个衔接笔画外轮廓的直线交点和内轮廓的直线交点。但是这样当折笔旋转过大时,会显得非常突兀,这时候就需要给转角加一个切角。我们可以根据切角大小计算出相应关键点,对于直线轮廓,计算切角关键点比较简单,但对于曲线轮廓,求交点时需要取相应锚点的切线来计算计算。另外如果切角会扩大至曲线上,切角关键点则需要在曲线上取相应距离的点,会稍微麻烦一点。以笔画“横撇”为例,假如我们需要切一个大小为50的切角,对于笔画横,直接取 外轮廓终点 - 50 处的点为切点,而对于笔画撇,则需要先计算出外轮廓曲线上的离散点,然后遍历离散点,当离散点距离总和达到50时,取该处的点为切点。

笔画骨架关键点的拖拽编辑

除了改变“长度”等直观参数,字玩同时支持用户通过拖拽骨架关键点的方式改变骨架。这时候,有个关键的问题就是当骨架改变之后,需要根据骨架重新计算“长度”等直观参数。对于“横”、“竖”、“折”来说,这个计算相对简单,然而对于“撇”、“捺”、“点”、“挑”来说,会稍微复杂一点————因为我们需要根据拐点位置计算出垂线长度与垂足位置,这需要一点基础数学知识。同时,在根据骨架关键点计算直观参数的时候,还需要特别注意参数边界的处理————用户很可能在拖拽过程中,将骨架关键点拖拽到边界以外的地方,这时候,需要自动将参数设置在边界处以防止字形绘制错误。

未来展望

基于黑体的笔画绘制与字形组装只是数字化、参数化探索中很基础的一步尝试,未来还有很多工作可以做。笔者希望首先完善黑体笔画的绘制,比如丰富风格参数,支持更多的笔画风格。这其中很有探索空间的地方就是“字重变化”。目前“字重变化”的设计比较简陋,但是通过对字重进行单元贝塞尔曲线的叠乘,使用类似缓动函数的原理,可以将笔画粗细变化调成任意风格,尽管还需要解决很多细节问题,但这将是个很有趣的数字化尝试。另外,笔者也希望将研究拓展到黑体以外,比如尝试宋体或楷体的笔画绘制。同时,图标字体也是笔者非常感兴趣的方向,字玩除了文字也支持图标绘制。图标通常形状更加简易,相对容易抽象出参数,在未来笔者也希望尝试使用程序绘制一些简单图标,制作一些基本图标的可调参模板。笔者水平非常有限,在探索过程中也经常出现力不从心的情况,写下此文也希望抛砖引玉,让更多朋友参与到中文字体数字化、参数化的研究探索中,为字体设计行业添砖加瓦。