前言
最近在开发一个要和字体打交道的工具,写完后端实现后想着写个能可视化字体、选择并生成配置的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
}
|
platformID和platformSpecificID都是为了一些兼容性问题而设计的,我们不用管,大概看看就好了。感兴趣的话可以自行查看TrueType Reference Manual。
值得注意的是,platformID的取值为(0, 1, 3),
这里JDK只处理MS_PLATFORM_ID 和 MAC_PLATFORM_ID 的情况,也就是1和3。
languageID是标记这个nameRecord中nameID字段使用的语言。
还记得前面看到的localeFamilyName吗?就是通过这个字段实现的。nameTable中有多个标记familyName的record,只需要匹配languageID就能找到本地化的familyName了。
nameID标记了这个record里存的是什么信息,这里节选一部分主要使用到的字段。
| nameID | 描述 | 示例(思源黑体) |
|---|
| 0 | 版权信息 | © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. |
| 1 | Family name | Source Han Sans SC |
| 2 | Sub-family name | Bold |
| 3 | 唯一标识(FontName + Style + Version + Vendor) | 2.004;ADBO;SourceHanSansSC-Bold;ADOBE |
| 4 | Full name | Source Han Sans SC Bold |
| 5 | 版本号 | Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603 |
| 6 | PostScript name | SourceHanSansSC-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