在各类页面开发中可能都会遇到一种需求,用户需要在某个地方展示一种特殊字体,如果是英文尚且还好,但如果是中文字体的话,这个字体很有可能有20MB~50MB的大小,只为了那一行字,就要导入这样大一个字体包,总会觉得有些不妥,应该怎么解决这样的问题呢?
第一感就是直接将文字提前做成图片,不引入字体,无论是项目的打包大小,还是运行内存上要稍好一些。但如果这样,就又会引出一个问题,如果字体如果要参与动画效果,直接用生成好的图片去做这种动画,效果多多少少会比矢量字体直接参与差一些,运算的消耗也要稍高一点,而且如果要求很清晰的图片,又是要加载一张大图才行。
遇到这样的问题,我便考虑到做成SVG,它也是矢量格式的图片,无论放到多大都一样清晰,做各种各样的动画效果都非常便捷,体积也非常小,而且在这样的文本文件上进行一些细小的改动也很方便,能更好的参与版本控制。
但又产生一个问题,将字体生成为点位图倒还容易,但要生成为SVG就不太简便了。网络上的在线工具,往往是限制大小的,中文字体文件都太大了;要么用专业软件,但安装很大一个软件而且操作麻烦不好用;要么是收费的,往往生成出来的效果也往往需要再处理一下。而且即便是生成好了,还要反复寻找自己需要的文字再拼接起来,这无疑让很多人放弃。其实自己做这样一个工具也非常简单,我们使用语法糖丰富的kotlin来实现这样一个有趣的工具,当然做好了这样一个工具,也不单纯是为了这一需求,我们开发人员不需要安装字体制作软件便可以观赏字体的细节了,也是十分有趣的。
引入
涉及字体与SVG,我选择的是Apache Batik
这个库,非常的轻便,虽然是老项目,但至今还在维护,虽然是Java库,但kotlin也是能无缝使用的,用kotlin来实现这个工具遇到需要重写的部分也能将代码变得更加清晰可读,下面先引入这个包:
dependencies {
implementation("org.apache.xmlgraphics:batik-svggen:1.16")
}
其实这个库,说起来是能将TTF转成SVG,但默认的一套模式也不尽人意,官方文档介绍的是命令行的方式,但这种事情不用代码进行调用是很难达到预期的效果的,命令行再多的参数,也很难完全满足需要,但巧的就是这个库就是不考虑软件的调用,只能用命令行,用命令行调用大致就是如下的方式:
我事先准备了一个字体包ShouShuti.ttf
,拷到项目目录里,这个字体文件其实有什么用什么就行了。
使用
将字体文名代入参数中,只需要一行代码即可:
fun main() {
SVGFont.main(arrayOf("ShouShuti.ttf", "-l", "19968", "-h", "40869", "-o", "output.svg", "-testcard"))
}
如官方文档所说,-l
是字体字符的起始位置,-h
是字体字符的结束位置,字体文件遵从Unicode的编码,基本汉字的范围在4E00~9FA5
,也就是19968~40869
之间,所以我设定这个范围,执行这段代码,就能看到生成好了一个output.svg
文件,内容如下:
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<defs>
<font horiz-adv-x="457"><font-face font-family="ShouShuti"
units-per-em="1000"
panose-1="2 1 8 0 4 1 1 1 1 1"
ascent="800"
descent="-236"
alphabetic="0" />
<missing-glyph horiz-adv-x="1000" />
<glyph unicode="就" horiz-adv-x="1000" d="..." />
<glyph ... />
...
</font>
</defs>
<g style="font-family:ShouShuti;...">
<text x="20" y="60">...</text>
...
</g>
</svg>
可以看到这是将字体全都转为glyph
预定好,然后下面text再去使用。这其实就是很多年前已经废除的SVG_fonts方案,该功能已从Chrome 38(和 Opera 25)中删除了,所以这种效果其实完全是不能接受的。我们想要的是一个直接画出文字的SVG,例如一些path
标签的叠加组成的文字,这可以实现吗,其实查看生成的svg文件就能发现是可以的。
解读源代码
查看最前面的一节标签,在defs>font>glyph
这一层的部分,是完整的绘出了字体的形状的,19968~40869
之间的汉字都在其中,其中每一个glyph
都是由d
来绘画出其unicode
属性对应的单个文字的,d
属性的部分和path
标签的d
属性是一样的用法,只要将这些glyph定义改成path,放到最外面一层就是可以的。那么如何改造这个方法呢?直接调用肯定是不行了,毕竟它只允许给出Unicode范围,那么只能先观察下SVGFont.main()
是怎么写的(去除了无用的代码注释):
入口部分
public static void main(String[] args) {
try {
String path = parseArgs(args, null);
String low = parseArgs(args, ARG_KEY_CHAR_RANGE_LOW);
String high = parseArgs(args, ARG_KEY_CHAR_RANGE_HIGH);
String id = parseArgs(args, ARG_KEY_ID);
String ascii = parseArgs(args, ARG_KEY_ASCII);
String testCard = parseArgs(args, ARG_KEY_TESTCARD);
String outPath = parseArgs(args, ARG_KEY_OUTPUT_PATH);
String autoRange = parseArgs(args, ARG_KEY_AUTO_RANGE);
PrintStream ps = null;
FileOutputStream fos = null;
// What are we outputting to?
if (outPath != null) {
// If an output path was specified, write to a file
fos = new FileOutputStream(outPath);
ps = new PrintStream(fos);
} else {
// Otherwise we'll just put it to stdout
ps = System.out;
}
// The font path is the only required argument
if (path != null) {
Font font = Font.create(path);
// Write the various parts of the SVG file
writeSvgBegin(ps);
writeSvgDefsBegin(ps);
writeFontAsSVGFragment(
ps,
font,
id,
(low != null ? Integer.parseInt(low) : -1),
(high != null ? Integer.parseInt(high) : -1),
(autoRange != null),
(ascii != null));
writeSvgDefsEnd(ps);
if (testCard != null) {
String fontFamily = font.getNameTable().getRecord(Table.nameFontFamilyName);
writeSvgTestCard(ps, fontFamily);
}
writeSvgEnd(ps);
// Close the output stream (if we have one)
if (fos != null) {
fos.close();
}
} else {
usage();
}
} catch (Exception e) {
e.printStackTrace();
System.err.println(e.getMessage());
usage();
}
}
前面参数设定的部分可以跳过,往后面看就能发现实际上发挥作用的就是writeFontAsSVGFragment()
,这个方法写出了字体数据,而在他前后的方法都只是写出一些必要的定义标签,能否直接去控制这个方法来生成呢?显然是不行的,因为访问级别是protected
的,不过这个方法相对独立,所以抽出来我们自己重新写一遍就好,不过这个方法内容相当多,如下所示(去除了无用的代码注释):
写出字体数据标签
public static void writeFontAsSVGFragment(PrintStream ps, Font font, String id, int first, int last, boolean autoRange, boolean forceAscii)
throws Exception {
int horiz_advance_x = font.getOS2Table().getAvgCharWidth();
ps.print(XML_OPEN_TAG_START);
ps.print(SVG_FONT_TAG);
ps.print(XML_SPACE);
if (id != null) {
ps.print(SVG_ID_ATTRIBUTE);
ps.print(XML_EQUAL_QUOT);
ps.print(id);
ps.print(XML_CHAR_QUOT);
ps.print(XML_SPACE);
}
ps.print(SVG_HORIZ_ADV_X_ATTRIBUTE);
ps.print(XML_EQUAL_QUOT);
ps.print(horiz_advance_x);
ps.print(XML_CHAR_QUOT);
ps.print(XML_OPEN_TAG_END_CHILDREN);
ps.print(getSVGFontFaceElement(font));
// Decide upon a cmap table to use for our character to glyph look-up
CmapFormat cmapFmt = null;
if (forceAscii) {
// We've been asked to use the ASCII/Macintosh cmap format
cmapFmt = font.getCmapTable().getCmapFormat(
Table.platformMacintosh,
Table.encodingRoman);
} else {
// The default behaviour is to use the Unicode cmap encoding
cmapFmt = font.getCmapTable().getCmapFormat(
Table.platformMicrosoft,
Table.encodingUGL);
if (cmapFmt == null) {
// This might be a symbol font, so we'll look for an "undefined" encoding
cmapFmt = font.getCmapTable().getCmapFormat(
Table.platformMicrosoft,
Table.encodingUndefined);
}
}
if (cmapFmt == null) {
throw new Exception("Cannot find a suitable cmap table");
}
// If this font includes arabic script, we want to specify
// substitutions for initial, medial, terminal & isolated
// cases.
GsubTable gsub = (GsubTable) font.getTable(Table.GSUB);
SingleSubst initialSubst = null;
SingleSubst medialSubst = null;
SingleSubst terminalSubst = null;
if (gsub != null) {
Script s = gsub.getScriptList().findScript(SCRIPT_TAG_ARAB);
if (s != null) {
LangSys ls = s.getDefaultLangSys();
if (ls != null) {
Feature init = gsub.getFeatureList().findFeature(ls, FEATURE_TAG_INIT);
Feature medi = gsub.getFeatureList().findFeature(ls, FEATURE_TAG_MEDI);
Feature fina = gsub.getFeatureList().findFeature(ls, FEATURE_TAG_FINA);
if (init != null) {
initialSubst = (SingleSubst)
gsub.getLookupList().getLookup(init, 0).getSubtable(0);
}
if (medi != null) {
medialSubst = (SingleSubst)
gsub.getLookupList().getLookup(medi, 0).getSubtable(0);
}
if (fina != null) {
terminalSubst = (SingleSubst)
gsub.getLookupList().getLookup(fina, 0).getSubtable(0);
}
}
}
}
// Include the missing glyph
ps.println(getGlyphAsSVG(font, font.getGlyph(0), 0, horiz_advance_x,
initialSubst, medialSubst, terminalSubst, ""));
try {
if (first == -1) {
if (!autoRange) first = DEFAULT_FIRST;
else first = cmapFmt.getFirst();
}
if (last == -1) {
if (!autoRange) last = DEFAULT_LAST;
else last = cmapFmt.getLast();
}
// Include our requested range
Set glyphSet = new HashSet();
for (int i = first; i <= last; i++) {
int glyphIndex = cmapFmt.mapCharCode(i);
if (glyphIndex > 0) {
// add glyph ID to set so we can filter later
glyphSet.add(glyphIndex);
ps.println(getGlyphAsSVG(
font,
font.getGlyph(glyphIndex),
glyphIndex,
horiz_advance_x,
initialSubst, medialSubst, terminalSubst,
(32 <= i && i <= 127) ?
encodeEntities(String.valueOf((char) i)) :
XML_CHAR_REF_PREFIX + Integer.toHexString(i) + XML_CHAR_REF_SUFFIX));
}
}
// Output kerning pairs from the requested range
KernTable kern = (KernTable) font.getTable(Table.kern);
if (kern != null) {
KernSubtable kst = kern.getSubtable(0);
PostTable post = (PostTable) font.getTable(Table.post);
for (int i = 0; i < kst.getKerningPairCount(); i++) {
KerningPair kpair = kst.getKerningPair(i);
// check if left and right are both in our glyph set
if (glyphSet.contains(kpair.getLeft()) && glyphSet.contains(kpair.getRight())) {
ps.println(getKerningPairAsSVG(kpair, post));
}
}
}
} catch (Exception e) {
System.err.println(e.getMessage());
}
ps.print(XML_CLOSE_TAG_START);
ps.print(SVG_FONT_TAG);
ps.println(XML_CLOSE_TAG_END);
}
解读字体数据步骤
上面的代码虽然内容多,但也就是分为几个步骤:
- 密密麻麻的
print()
了一堆东西,其实都是一些标签常量。 CmapFormat
的设定,这部分可以简化。- 关于字形变换的
gsub
,注释中提到是给阿拉伯文用的,其实中文也是需要的,不过这个东西可替代性强,可以去掉。 - 核心部分
glyph
,字形是关键所在,将glyf
和hmtx
的字形数据块绘制到glyph
标签中。 - 字距
hkern
,这个部分似乎很少有字体文件去做,一般都不用这个功能,也可以去掉。
看上去只要重写gsub
部分就行了,其他都没有牵连也不需要,但难点就在于负责描绘数据的getGlyphAsSVG()
和getContourAsSVGPathData()
都是protected
的方法,无奈只能把这个也提出来,顺便优化一成一个方法。那么下面就从这个方法开始我们的kotlin重写:
用kotlin重写
前面说了,由于几个重要的方法是protected
的原因,不得不复制出来用重写一份,这个getContourAsSVGPathData()
其实就是具体负责将字形数据glyph
转为SVG的路径描述(也就是d
属性)的,就不必贴batik的源代码了,因为几乎没有什么改动,这里直接贴出我改为kotlin后的getContourAsSVGPathData()
:
转换字形数据
fun getContourAsSVGPathData(glyph: Glyph, startIndex: Int, count: Int): String {
if (glyph.getPoint(startIndex).endOfContour) {
return ""
}
val sb = StringBuffer()
var offset = 0
while (offset < count) {
val point = glyph.getPoint(startIndex + offset % count)
val point_plus1 = glyph.getPoint(startIndex + (offset + 1) % count)
val point_plus2 = glyph.getPoint(startIndex + (offset + 2) % count)
if (offset == 0) {
sb.append('M')
.append(point.x)
.append(' ')
.append(point.y)
}
if (point.onCurve && point_plus1.onCurve) {
if (point_plus1.x == point.x) { // This is a vertical line
sb.append('V')
.append(point_plus1.y)
} else if (point_plus1.y == point.y) { // This is a horizontal line
sb.append('H')
.append(point_plus1.x)
} else {
sb.append('L')
.append(point_plus1.x)
.append(' ')
.append(point_plus1.y)
}
offset++
} else if (point.onCurve && !point_plus1.onCurve && point_plus2.onCurve) {
// This is a curve with no implied points
sb.append('Q')
.append(point_plus1.x)
.append(' ')
.append(point_plus1.y)
.append(' ')
.append(point_plus2.x)
.append(' ')
.append(point_plus2.y)
offset += 2
} else if (point.onCurve && !point_plus1.onCurve && !point_plus2.onCurve) {
// This is a curve with one implied point
sb.append('Q')
.append(point_plus1.x)
.append(' ')
.append(point_plus1.y)
.append(' ')
.append(midValue(point_plus1.x, point_plus2.x))
.append(' ')
.append(midValue(point_plus1.y, point_plus2.y))
offset += 2
} else if (!point.onCurve && !point_plus1.onCurve) {
// This is a curve with two implied points
sb.append('T')
.append(midValue(point.x, point_plus1.x))
.append(' ')
.append(midValue(point.y, point_plus1.y))
offset++
} else if (!point.onCurve && point_plus1.onCurve) {
sb.append('T')
.append(point_plus1.x)
.append(' ')
.append(point_plus1.y)
offset++
} else {
println("drawGlyph case not catered for!!")
break
}
}
sb.append('Z')
return sb.toString()
}
当然还要补充上所需的一个计算中间值的midValue()
,由于这个方法还是private
的,也只能改为kotlin贴出来了:
fun midValue(a: Int, b: Int): Int {
return a + (b - a) / 2
}
遍历字形区块
前面的getContourAsSVGPathData()
其实只是绘画一个字形的部分区域,阅读调用它的的getGlyphAsSVG()
就能发现,要循环字形的所有区域才能画完一个字形,因为将getGlyphAsSVG()
画了很多不必要的与字形定义相关的数据,所以我们将其改造成一个极简的glyphToPath()
方法,让它直接将字形转为许多个path
:
fun glyphToPath(ps: PrintStream, glyph: Glyph) {
var count = 0
var firstIndex = 0
for (i in 0 until glyph.pointCount) {
count++
if (glyph.getPoint(i).endOfContour) {
val contourAsSVGPathData = getContourAsSVGPathData(glyph, firstIndex, count)
if(contourAsSVGPathData.isNotEmpty()) {
ps.println("""
<path transform="translate(0, 1200)" d="$contourAsSVGPathData" />
""")
}
firstIndex = i + 1
count = 0
}
}
}
以glyph.pointCount
为范围循环很好理解,毕竟要画出所有的点位,path
标签的属性transform="translate(0, 1200)"
则是根据前面所使用的ShouShuti.ttf
实际效果偏移而来的,每个字体不同,要视情况而定。然后就是最后一步了,直接调用这个glyphToPath()
生成出具体的SVG文件,其实也是最简单的部分,就是一些参数的传递:
设定参数生成文件
fun main() {
val path = Paths.get("ShouShuti.ttf").toAbsolutePath().toString()
val fos = FileOutputStream("output.svg")
val ps = PrintStream(fos)
val font = Font.create(path)
var cmapFmt = font.cmapTable.getCmapFormat(
Table.platformMicrosoft, Table.encodingUGL
)
val glyphIndex = cmapFmt.mapCharCode('永'.code)
val glyph = font.getGlyph(glyphIndex)
ps.println("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"2000\" style=\"zoom: 0.1;transform: rotateX(180deg);\">")
glyphToPath(ps, glyph)
ps.println("</svg>")
fos.close()
}
还是用前面的ShouShuti.ttf
文件,这个ttf用什么都可以,但是要根据实际效果来调整glyphToPath
中path
的定位,至于输出就更是随意了,只要给一个PrintStream
就行,直接用System.out
都可以的。然后就是通过CmapFormat
获取到glyph
了,(获取时用的platform
和Table.encodingUGL
一般情况下不用改的),先写好前后规定好的svg
标签格式(用zoom
缩小是因为字体的点位一般都很大,rotateX
翻转也是字体文件的惯例),然后将PrintStream
和glyph
传递给上面写好了的glyphToPath()
,就是这样一个过程。测试用字此处遵从中文字体设计“永字八法”的惯例,使用“永”这个字测试字体效果,生成的文件效果如下:
这个锯齿是因为字体设计时本身就有锯齿可以忽略,可以看到画成了4个path
表示这个字形的4块区域,如何看到这4个区域的具体部分呢,其实可以改动一下glyphToPath()
中path
标签的打印,在打印时填充一下用随机颜色就能看清了。
随机每个字形区域颜色
ps.println(
"""
<path transform="translate(0, 1200)" d="$contourAsSVGPathData" fill="
#${Integer.toHexString(kotlin.math.floor(Math.random() * 0xeff).toInt() + 0x100)}" />"""
)
这个随机颜色使用hex色彩值,正常应当是0x000~0xfff
的范围,但因为0x100
以下的数字要在前面补充0,这里图个方便就设为#100~#fff
的随机色彩了,效果如下
可以看到4个path对应4个笔画区域,十分清晰,再改用笔画繁多的“繁”字测试一下:
也是很清晰的看到各个笔画的字形区域。
单个区域的实现
当然不是每个字体都能这么玩的,很多字体不是这样手写上去的,而是使用比较的少的点位画出圆滑方正的字体,换一个“上首漠云体”的字体(path
属性调为transform="translate(0, 500)"
)就能看出效果:
这个字体的字形区域就是一块一块的,许多字体设计时就是这样设计的,如果不随机颜色,他就会变成这样:
字形中“母”的部分变成了一团,这是因为中间的空白是后面两个path画上去的,而path默认都是黑色的,画这两个特殊的path时也没有什么特殊标识可用,就只能一个字画成一个path了,大部分字体其实都得采用这种方式,也就是改动glyphToPath()
的部分:
fun glyphToPath(ps: PrintStream, glyph: Glyph) {
var count = 0
var firstIndex = 0
ps.print("""<path transform="translate(0, 500)" d="""")
for (i in 0 until glyph.pointCount) {
count++
if (glyph.getPoint(i).endOfContour) {
val contourAsSVGPathData = getContourAsSVGPathData(glyph, firstIndex, count)
if (contourAsSVGPathData.isNotEmpty()) {
ps.print(contourAsSVGPathData)
}
firstIndex = i + 1
count = 0
}
}
ps.println("\" />")
}
代码改动很简单,就是把path
标签的定义放到d
属性的前后,所有字形都集合成一个单独的path
,也就是一整块区域,字形效果也是正确的,效果如下:
以上就是利用batik
的原始功能,稍加改造实现的TTF转SVG功能,比较有趣。可以作为一个工具使用,获取字体的矢量结构,展示在一些适合SVG的界面上。
本文写作于2023年5月10日发布于lyrieek的掘金,于2023年7月15日进行修订发布于lyrieek的阿里云开发者社区。