使用batik在kotlin中将TTF字体转换为SVG图像

简介: 将TTF字体自由的转换为SVG图像是一个有趣的媒体交换,如何实现这一冷门的需求呢?本文参考batik的源代码并利用其中一些功能实现这个有创造力的小功能。

在各类页面开发中可能都会遇到一种需求,用户需要在某个地方展示一种特殊字体,如果是英文尚且还好,但如果是中文字体的话,这个字体很有可能有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="&#x5c31;" 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);
}

解读字体数据步骤

上面的代码虽然内容多,但也就是分为几个步骤:

  1. 密密麻麻的print()了一堆东西,其实都是一些标签常量。
  2. CmapFormat的设定,这部分可以简化。
  3. 关于字形变换的gsub,注释中提到是给阿拉伯文用的,其实中文也是需要的,不过这个东西可替代性强,可以去掉。
  4. 核心部分glyph,字形是关键所在,将glyfhmtx的字形数据块绘制到glyph标签中。
  5. 字距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用什么都可以,但是要根据实际效果来调整glyphToPathpath的定位,至于输出就更是随意了,只要给一个PrintStream就行,直接用System.out都可以的。然后就是通过CmapFormat获取到glyph了,(获取时用的platformTable.encodingUGL一般情况下不用改的),先写好前后规定好的svg标签格式(用zoom缩小是因为字体的点位一般都很大,rotateX翻转也是字体文件的惯例),然后将PrintStreamglyph传递给上面写好了的glyphToPath(),就是这样一个过程。测试用字此处遵从中文字体设计“永字八法”的惯例,使用“永”这个字测试字体效果,生成的文件效果如下:

image.png

这个锯齿是因为字体设计时本身就有锯齿可以忽略,可以看到画成了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的随机色彩了,效果如下

image.png

可以看到4个path对应4个笔画区域,十分清晰,再改用笔画繁多的“繁”字测试一下:

image.png

也是很清晰的看到各个笔画的字形区域。

单个区域的实现

当然不是每个字体都能这么玩的,很多字体不是这样手写上去的,而是使用比较的少的点位画出圆滑方正的字体,换一个“上首漠云体”的字体(path属性调为transform="translate(0, 500)")就能看出效果:

image.png

这个字体的字形区域就是一块一块的,许多字体设计时就是这样设计的,如果不随机颜色,他就会变成这样:

image.png

字形中“母”的部分变成了一团,这是因为中间的空白是后面两个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,也就是一整块区域,字形效果也是正确的,效果如下:
image.png

以上就是利用batik的原始功能,稍加改造实现的TTF转SVG功能,比较有趣。可以作为一个工具使用,获取字体的矢量结构,展示在一些适合SVG的界面上。

本文写作于2023年5月10日发布于lyrieek的掘金,于2023年7月15日进行修订发布于lyrieek的阿里云开发者社区。

目录
相关文章
|
前端开发 API
css:网页引入字体@font-face以及动态加载字体
css:网页引入字体@font-face以及动态加载字体
280 0
css:网页引入字体@font-face以及动态加载字体
|
10月前
|
开发者 Kotlin
将JPG图像根据色彩范围转换为PNG透明图像(kotlin)
这实际上是一个十分普遍的需求,在kotlin中如何完成这一任务呢?其实这样简单的操作不需要任何三方库,只需要BufferedImage的原生功能就能做到,约60行代码
127 0
python 将绘制的图片保存为矢量图格式(svg)
python 将绘制的图片保存为矢量图格式(svg)
python 将绘制的图片保存为矢量图格式(svg)
|
编解码 前端开发
2023年你应该需要知道的CSS新特性-图形与图像
前一段时间State of CSS发起了2022年的调查问卷,该文件的内容主要是CSS新特性、框架、工具库的使用情况,这里我将会用几篇文章整理一下这个问卷中涉及到的新特性
120 0
|
开发者
Font-Awesome如何引入矢量字体图标
在开发网页的过程中,我们会经常需要用到一些小图标来进行形象地说明解释或者装饰网页,但是传统的图片引用方式引入的的是图像图标,不易修改,而矢量字体图标则能很好地解决这一问题,因为矢量字体图标的本质是字体,可以使用“<style>”标签对其属性进行修改,非常方便,已经被广泛应用于网页开发中!本文主要介绍font-awesome-4.7.0的引入和使用
309 0
Font-Awesome如何引入矢量字体图标
|
JavaScript Android开发 开发者
autojs颜色转换rgb与hsl互转
牙叔教程 简单易学
177 0
|
XML 数据格式
关于字体编码的一些知识,并带大家制作一套字体。(上)
关于字体编码的一些知识,并带大家制作一套字体。
关于字体编码的一些知识,并带大家制作一套字体。(上)
sketch 如何规范的设置自己的字体样式库( Text styles )
sketch 如何规范的设置自己的字体样式库( Text styles )
sketch 如何规范的设置自己的字体样式库( Text styles )
|
前端开发 流计算
《图解CSS》字体与文本
* 字体是“文字的不同体式”或者“字的形体结构”,例如宋体/黑体/行楷等。 * 文本就是一组字或字符,比如章标题、段落正文等等,跟使用什么字体无关。 * CSS 为字体和文本分别定义了属性。字体属性主要描述一类字体的大小和外观。比如,使用什么字体族(是 Times,还是 Helvitica),多大字号,粗体还是斜体。文本属性描述对文本的处理方式。比如,行高或者字符间距多大,有没有下划线和缩进。
82 0