Java sun.font.TrueTypeFont源码分析

前言

最近在开发一个要和字体打交道的工具,写完后端实现后想着写个能可视化字体、选择并生成配置的GUI工具。但是遇到了一个问题:Java导出的Family name匹配不上Matplot端的,导致找不到字体。没办法,只能在Python端选择自己实现字体搜索算法了。在那之前,我们需要搞明白Java侧是怎么设置字体参数的。 本次分析选择OpenJDK 27,源码在这里

正文

进入TrueTypeFont类,首先看到了一堆tag。我们先不管他,往下翻找到我们关心的familyName字段。

1
2
private String localeFamilyName;
private String localeFullName;

可以看到没有familyName字段,说明它定义在父类里。那我们就顺着localeFamilyName找吧,反正都是要初始化的。

Ctrl+F搜索,下一个引用位置就是initNames函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
protected void initNames() {
    byte[] name = new byte[256];
    ByteBuffer buffer = getTableBuffer(nameTag);
    if (buffer != null) {
        ShortBuffer sbuffer = buffer.asShortBuffer();
        sbuffer.get(); // format - not needed.
        short numRecords = sbuffer.get();
        /* The name table uses unsigned shorts. Many of these
         * are known small values that fit in a short.
         * The values that are sizes or offsets into the table could be
         * greater than 32767, so read and store those as ints
         */
        int stringPtr = sbuffer.get() & 0xffff;
        nameLocale = sun.awt.SunToolkit.getStartupLocale();
        short nameLocaleID = getLCIDFromLocale(nameLocale);
        languageCompatibleLCIDs =
            getLanguageCompatibleLCIDsFromLocale(nameLocale);
        for (int i=0; i<numRecords; i++) {
            short platformID = sbuffer.get();
            if (platformID != MS_PLATFORM_ID &&
                platformID != MAC_PLATFORM_ID) {
                sbuffer.position(sbuffer.position()+5);
                continue; // skip over this record.
            }
            short encodingID = sbuffer.get();
            short langID     = sbuffer.get();
            short nameID     = sbuffer.get();
            int nameLen    = ((int) sbuffer.get()) & 0xffff;
            int namePtr    = (((int) sbuffer.get()) & 0xffff) + stringPtr;
            String tmpName = null;
            // only want MacRoman encoding and English name on Mac.
            if ((platformID == MAC_PLATFORM_ID) &&
                (encodingID != MACROMAN_SPECIFIC_ID ||
                 langID != MACROMAN_ENGLISH_LANG)) {
                continue;
            }
            switch (nameID) {
            case FAMILY_NAME_ID:
                boolean compatible = false;
                if (familyName == null || langID == ENGLISH_LOCALE_ID ||
                    langID == nameLocaleID ||
                    (localeFamilyName == null &&
                     (compatible = isLanguageCompatible(langID))))
                {
                    buffer.position(namePtr);
                    buffer.get(name, 0, nameLen);
                    tmpName = makeString(name, nameLen, platformID, encodingID);
                    if (familyName == null || langID == ENGLISH_LOCALE_ID){
                        familyName = tmpName;
                    }
                    if (langID == nameLocaleID ||
                        (localeFamilyName == null && compatible))
                    {
                        localeFamilyName = tmpName;
                    }
                }

                break;
            case FULL_NAME_ID:
                compatible = false;
                if (fullName == null || langID == ENGLISH_LOCALE_ID ||
                    langID == nameLocaleID ||
                    (localeFullName == null &&
                     (compatible = isLanguageCompatible(langID))))
                {
                    buffer.position(namePtr);
                    buffer.get(name, 0, nameLen);
                    tmpName = makeString(name, nameLen, platformID, encodingID);
                    if (fullName == null || langID == ENGLISH_LOCALE_ID) {
                        fullName = tmpName;
                    }
                    if (langID == nameLocaleID ||
                        (localeFullName == null && compatible))
                    {
                        localeFullName = tmpName;
                    }
                }
                break;
            }
        }
        if (localeFamilyName == null) {
            localeFamilyName = familyName;
        }
        if (localeFullName == null) {
            localeFullName = fullName;
        }
    }
}

快速预览一下,这个方法大概干了这些事情:

  • 读取Name Table到buffer
  • 从buffer读取header
  • 遍历nameRecord并解析元数据,这里是我们需要关注的地方。 我们一步步来分析。

首先是 读取到buffer,没什么好说的,只需要注意一下每个单位是uint16(两个byte)。

然后是从buffer读取header。header的结构如下:

1
2
3
4
5
{
	format: uint16,
	nameRecordNum: uint16,
	stringOffset: uint16
}

根据TrueType Reference Manual,对于TrueType文件format固定为0,所以 源码中跳过了。

numRecords就是NameRecord结构体的个数;stringOffset是指字符串段的起始偏移。
我们只要seek到stringOffset + nameRecord.offset,就能拿到对应record存下的string。

读取stringPtr的这一行可能有一些迷惑,实际上就是把stringOffset映射成无符号的short(因为Java中不存在uint16,所以这里取低四位并转换为int,符号位就固定为0了)。

解析nameRecord需要的长度拿到了,接下来开始解析。每个NameRecord的结构如下:

1
2
3
4
5
6
7
8
{
	platformID: uint16,
	platformSpecificID: uint16,
	languageID: uint16,
	nameID: uint16,
	length: uint16,
	offset: uint16
}

platformIDplatformSpecificID都是为了一些兼容性问题而设计的,我们不用管,大概看看就好了。感兴趣的话可以自行查看TrueType Reference Manual

值得注意的是,platformID的取值为(0, 1, 3), 这里JDK只处理MS_PLATFORM_ID 和 MAC_PLATFORM_ID 的情况,也就是1和3。

languageID是标记这个nameRecordnameID字段使用的语言。
还记得前面看到的localeFamilyName吗?就是通过这个字段实现的。nameTable中有多个标记familyName的record,只需要匹配languageID就能找到本地化的familyName了。

nameID标记了这个record里存的是什么信息,这里节选一部分主要使用到的字段。

nameID描述示例(思源黑体)
0版权信息© 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’.
1Family nameSource Han Sans SC
2Sub-family nameBold
3唯一标识(FontName + Style + Version + Vendor)2.004;ADBO;SourceHanSansSC-Bold;ADOBE
4Full nameSource Han Sans SC Bold
5版本号Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603
6PostScript nameSourceHanSansSC-Bold

我们可以发现,其实主要逻辑在 这个大switch块。它匹配了FAMILY_NAME_ID(1)和FULL_NAME_ID(4)两个nameID,并且两个case的结构几乎一模一样。

第一步, 检查当前familyName是否已被设置,如果是则跳过当前nameRecord。

第二步, 检查languageID是否适配startupLocale。如果是,认为当前nameRecord是localized的, 并保存到localeFamilyName或localeFullName字段。

第三步, 读取对应的string,并保存到familyName或fullName字段

所以看到这里其实已经真相大白了,我们通过java.awt.Font#getFamily拿到的就是ttf中第一个nameID == 1的记录,通过java.awt.Font#getFontName拿到的是ttf中第一个nameID == 4的记录。
想要实现一致的行为,我们只需要仿照这个去解析字体文件就可以了。我的目的是匹配对应字体文件,所以我选择在Java侧使用getFontName读取字体的Full name,在Python侧解析匹配nameID == 4并拿到字体名称。

后记

其实笔者一开始是直接从IntelliJ IDEA分析反编译后的class,写到一半觉得还是分析开源的比较好。同时我也发现不同JDK对字体的处理有许多不同,比如JBR直接在类里面定义了postScriptName字段,而OpenJDK则是选择重写了getPostscriptName方法并在调用时按需解析。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Postscript name is rarely requested. Don't waste cycles locating it
 * as part of font creation, nor storage to hold it. Get it only on demand.
 */
@Override
public String getPostscriptName() {
    String name = lookupName(ENGLISH_LOCALE_ID, POSTSCRIPT_NAME_ID);
    if (name == null) {
        return fullName;
    } else {
        return name;
    }
}

所以……反射这些内部的状态还是能不用就不用吧。使用任何未公开API的方法和字段都是未定义行为XD

Licensed under CC BY-NC-SA 4.0