feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+161 -2
View File
@@ -10,6 +10,7 @@ export const MAXIMUM_DOCX_FONT_SFNT_BYTES = 16 * 1024 * 1024;
export const MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES =
48 * 1024 * 1024;
const MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES = 8;
const SFNT_CHECKSUM_MAGIC = 0xb1b0afba;
const fontConversionCache = new Map<
string,
Promise<Uint8Array>
@@ -42,6 +43,21 @@ export interface SfntFontMetadata {
subfamily: string;
postscriptName?: string;
weightClass: number;
panose: readonly [
number,
number,
number,
number,
number,
number,
number,
number,
number,
number
];
unicodeRanges: readonly [number, number, number, number];
codePageRanges: readonly [number, number];
fixedPitch: boolean;
fsType: number;
permission: DocxFontEmbeddingPermission;
noSubsetting: boolean;
@@ -64,6 +80,7 @@ export interface ObfuscatedDocxFont {
interface SfntTable {
offset: number;
length: number;
recordOffset: number;
}
function readTag(content: Uint8Array, offset: number) {
@@ -160,7 +177,7 @@ function readSfntTables(content: Uint8Array) {
if (tables.has(tag)) {
throw new Error(`SFNT 字体包含重复表:${tag}`);
}
tables.set(tag, { offset, length });
tables.set(tag, { offset, length, recordOffset });
}
return {
@@ -170,6 +187,80 @@ function readSfntTables(content: Uint8Array) {
};
}
function calculateSfntChecksum(
content: Uint8Array,
offset = 0,
length = content.byteLength
) {
assertRange(content, offset, length, "校验和");
let checksum = 0;
const paddedLength = Math.ceil(length / 4) * 4;
for (
let relativeOffset = 0;
relativeOffset < paddedLength;
relativeOffset += 4
) {
let word = 0;
for (let byteIndex = 0; byteIndex < 4; byteIndex += 1) {
const index = relativeOffset + byteIndex;
word =
(word << 8) |
(index < length ? (content[offset + index] ?? 0) : 0);
}
checksum = (checksum + (word >>> 0)) >>> 0;
}
return checksum;
}
function normalizeSfntWeightClass(
content: Uint8Array,
weightClass: number
) {
if (
!Number.isInteger(weightClass) ||
weightClass < 1 ||
weightClass > 1000
) {
throw new Error("字体声明权重必须位于 1 到 1000 之间");
}
const { tables, view } = readSfntTables(content);
const os2 = tables.get("OS/2");
const head = tables.get("head");
if (!os2 || os2.length < 6) {
throw new Error("SFNT 字体缺少可写入权重的 OS/2 表");
}
if (!head || head.length < 12) {
throw new Error("SFNT 字体缺少完整 head 表");
}
if (view.getUint16(os2.offset + 4, false) === weightClass) {
return;
}
view.setUint16(os2.offset + 4, weightClass, false);
view.setUint32(
os2.recordOffset + 4,
calculateSfntChecksum(content, os2.offset, os2.length),
false
);
const adjustmentOffset = head.offset + 8;
view.setUint32(adjustmentOffset, 0, false);
view.setUint32(
head.recordOffset + 4,
calculateSfntChecksum(content, head.offset, head.length),
false
);
const checksum = calculateSfntChecksum(content);
view.setUint32(
adjustmentOffset,
(SFNT_CHECKSUM_MAGIC - checksum) >>> 0,
false
);
if (calculateSfntChecksum(content) !== SFNT_CHECKSUM_MAGIC) {
throw new Error("SFNT 字体元数据规范化后校验和无效");
}
}
function decodeUtf16Be(content: Uint8Array) {
if (content.byteLength % 2 !== 0) {
throw new Error("字体名称 UTF-16BE 长度无效");
@@ -334,12 +425,16 @@ export function readSfntFontMetadata(
const { signature, tables, view } = readSfntTables(content);
const os2 = tables.get("OS/2");
const name = tables.get("name");
if (!os2 || os2.length < 10) {
const post = tables.get("post");
if (!os2 || os2.length < 68) {
throw new Error("SFNT 字体缺少完整 OS/2 表");
}
if (!name) {
throw new Error("SFNT 字体缺少 name 表");
}
if (post && post.length < 16) {
throw new Error("SFNT 字体 post 表不完整");
}
const family = readFontName(content, name, [16, 1]);
const subfamily = readFontName(content, name, [17, 2]);
@@ -354,6 +449,52 @@ export function readSfntFontMetadata(
);
const permission = resolveFontEmbeddingPermission(fsType);
const postscriptName = readFontName(content, name, [6]);
const panose = Array.from(
content.subarray(os2.offset + 32, os2.offset + 42)
) as unknown as SfntFontMetadata["panose"];
const unicodeRanges = [
readUint32(
view,
content,
os2.offset + 42,
"OS/2 ulUnicodeRange1"
),
readUint32(
view,
content,
os2.offset + 46,
"OS/2 ulUnicodeRange2"
),
readUint32(
view,
content,
os2.offset + 50,
"OS/2 ulUnicodeRange3"
),
readUint32(
view,
content,
os2.offset + 54,
"OS/2 ulUnicodeRange4"
)
] as const;
const codePageRanges =
os2.length >= 86
? ([
readUint32(
view,
content,
os2.offset + 78,
"OS/2 ulCodePageRange1"
),
readUint32(
view,
content,
os2.offset + 82,
"OS/2 ulCodePageRange2"
)
] as const)
: ([0, 0] as const);
return {
family,
@@ -365,6 +506,18 @@ export function readSfntFontMetadata(
os2.offset + 4,
"OS/2 usWeightClass"
),
panose,
unicodeRanges,
codePageRanges,
fixedPitch:
post === undefined
? false
: readUint32(
view,
content,
post.offset + 12,
"post isFixedPitch"
) !== 0,
fsType,
permission,
noSubsetting: (fsType & 0x0100) !== 0,
@@ -423,7 +576,13 @@ export async function prepareDocxFont(
});
}
const sfnt = (await conversion).slice();
normalizeSfntWeightClass(sfnt, source.weight);
const metadata = readSfntFontMetadata(sfnt);
if (metadata.weightClass !== source.weight) {
throw new Error(
`字体内部权重 ${metadata.weightClass} 与声明权重 ${source.weight} 不一致`
);
}
const fingerprint = createHash("sha256")
.update(sfnt)
.digest("hex");
@@ -19,6 +19,7 @@ import {
readGeneratedDocxPackage,
writeGeneratedDocxPackage
} from "./reference-package.js";
import { createWordFontMetadata } from "./word-font-metadata.js";
const FONT_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
@@ -318,17 +319,49 @@ function updateFontTable(
font
])
);
const metadataFamilies = new Set<string>();
for (const face of faces) {
const family = face.font.metadata.family;
let font = byName.get(normalizeFontName(family));
const normalizedFamily = normalizeFontName(family);
let font = byName.get(normalizedFamily);
if (!font) {
font = appendElement(root, WORD_NAMESPACE, "w:font", {
"w:name": family
});
appendElement(font, WORD_NAMESPACE, "w:family", {
"w:val": "auto"
byName.set(normalizedFamily, font);
}
if (!metadataFamilies.has(normalizedFamily)) {
const metadata = createWordFontMetadata(face.font.metadata);
removeDirectChildren(
font,
WORD_NAMESPACE,
"panose1",
"charset",
"family",
"pitch",
"sig"
);
appendElement(font, WORD_NAMESPACE, "w:panose1", {
"w:val": metadata.panose1
});
byName.set(normalizeFontName(family), font);
appendElement(font, WORD_NAMESPACE, "w:charset", {
"w:val": metadata.charset
});
appendElement(font, WORD_NAMESPACE, "w:family", {
"w:val": metadata.family
});
appendElement(font, WORD_NAMESPACE, "w:pitch", {
"w:val": metadata.pitch
});
appendElement(font, WORD_NAMESPACE, "w:sig", {
"w:usb0": metadata.signature.usb0,
"w:usb1": metadata.signature.usb1,
"w:usb2": metadata.signature.usb2,
"w:usb3": metadata.signature.usb3,
"w:csb0": metadata.signature.csb0,
"w:csb1": metadata.signature.csb1
});
metadataFamilies.add(normalizedFamily);
}
appendElement(font, WORD_NAMESPACE, `w:${face.role}`, {
"r:id": face.relationshipId,
+1
View File
@@ -18,3 +18,4 @@ export * from "./style-presets.js";
export * from "./styles-transform.js";
export * from "./token-style-map.js";
export * from "./validator.js";
export * from "./word-font-metadata.js";
+56 -7
View File
@@ -108,6 +108,7 @@ function setParagraphSpacing(
options: {
beforePt: number;
afterPt: number;
fontSizePt: number;
lineSpacing: number;
firstLineIndentChars?: number;
}
@@ -116,8 +117,10 @@ function setParagraphSpacing(
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:before": pointsToTwips(options.beforePt),
"w:after": pointsToTwips(options.afterPt),
"w:line": String(Math.round(options.lineSpacing * 240)),
"w:lineRule": "auto"
"w:line": pointsToTwips(
options.fontSizePt * options.lineSpacing
),
"w:lineRule": "exact"
});
if (options.firstLineIndentChars !== undefined) {
appendElement(parent, WORD_NAMESPACE, "w:ind", {
@@ -208,6 +211,38 @@ function fallbackFontsForSlot(
return fallback.body.fonts;
}
function fallbackFontSizeForSlot(
slot: DocxStyleSlotName,
fallback: ResolvedDocxThemeStyle
) {
const headingMatch = /^heading-([1-6])$/u.exec(slot);
if (headingMatch) {
return fallback.headings.sizesPt[
Number.parseInt(headingMatch[1]!, 10) - 1
]!;
}
if (slot === "document-title") {
return fallback.headings.sizesPt[0] + 4;
}
if (slot.endsWith("-title")) {
return fallback.headings.sizesPt[0];
}
if (slot === "inline-code" || slot === "code-block") {
return fallback.code.sizePt;
}
if (
slot === "table" ||
slot === "table-header" ||
slot === "table-cell"
) {
return fallback.table.sizePt;
}
if (slot === "caption") {
return fallback.caption.sizePt;
}
return fallback.body.sizePt;
}
function applyTokenRunStyle(
parent: XmlElement,
token: DocxSlotStyleToken,
@@ -306,7 +341,8 @@ function applyTokenBorders(
function applyTokenParagraphStyle(
parent: XmlElement,
token: DocxSlotStyleToken
token: DocxSlotStyleToken,
fallbackFontSizePt: number
) {
if (
token.spacingBeforePt !== undefined ||
@@ -336,9 +372,12 @@ function applyTokenParagraphStyle(
setWordAttribute(
spacing,
"line",
String(Math.round(token.lineSpacing * 240))
pointsToTwips(
(token.fontSizePt ?? fallbackFontSizePt) *
token.lineSpacing
)
);
setWordAttribute(spacing, "lineRule", "auto");
setWordAttribute(spacing, "lineRule", "exact");
}
}
if (
@@ -440,6 +479,7 @@ function applyBodyStyle(
setParagraphSpacing(defaultParagraph, {
beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars
});
@@ -466,6 +506,7 @@ function applyBodyStyle(
setParagraphSpacing(pPr, {
beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars
});
@@ -492,6 +533,7 @@ function applyBodyStyle(
{
beforePt: 0,
afterPt: 0,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0
}
@@ -525,6 +567,7 @@ function applyHeadings(
setParagraphSpacing(pPr, {
beforePt: style.headings.spacingBeforePt,
afterPt: style.headings.spacingAfterPt,
fontSizePt: sizePt,
lineSpacing: 1.25
});
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) {
@@ -569,6 +612,7 @@ function applyHeadings(
setParagraphSpacing(pPr, {
beforePt: 0,
afterPt: style.headings.spacingAfterPt,
fontSizePt: sizePt,
lineSpacing: 1.25
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
@@ -622,6 +666,7 @@ function applyCodeAndQuote(
setParagraphSpacing(codePPr, {
beforePt: 3,
afterPt: 3,
fontSizePt: style.code.sizePt,
lineSpacing: style.code.lineSpacing,
firstLineIndentChars: 0
});
@@ -668,6 +713,7 @@ function applyCodeAndQuote(
setParagraphSpacing(quotePPr, {
beforePt: 6,
afterPt: 6,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0
});
@@ -813,6 +859,7 @@ function applyTableAndCaption(
setParagraphSpacing(pPr, {
beforePt: 3,
afterPt: 6,
fontSizePt: style.caption.sizePt,
lineSpacing: 1.25
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
@@ -1002,7 +1049,8 @@ function applyTokenStyles(
WORD_NAMESPACE,
"w:pPr"
),
documentToken
documentToken,
documentToken.fontSizePt ?? fallback.body.sizePt
);
}
}
@@ -1025,7 +1073,8 @@ function applyTokenStyles(
WORD_NAMESPACE,
"w:pPr"
),
entry.style
entry.style,
fallbackFontSizeForSlot(slot, fallback)
);
}
applyTokenRunStyle(
+58
View File
@@ -14,6 +14,7 @@ import {
readSfntFontMetadata,
xorDocxFont
} from "./font-embedding.js";
import { createWordFontMetadata } from "./word-font-metadata.js";
import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
@@ -355,6 +356,7 @@ function validateEmbeddedFonts(
for (const font of directChildren(root, WORD_NAMESPACE, "font")) {
const family =
font.getAttributeNS(WORD_NAMESPACE, "name") ?? "";
let validatedMetadata = false;
for (const role of [
"embedRegular",
"embedBold",
@@ -420,6 +422,62 @@ function validateEmbeddedFonts(
`嵌入字体家族名不匹配:${family} != ${metadata.family}`
);
}
if (!validatedMetadata) {
const expected = createWordFontMetadata(metadata);
const panose1 = directChildren(
font,
WORD_NAMESPACE,
"panose1"
)[0];
const charset = directChildren(
font,
WORD_NAMESPACE,
"charset"
)[0];
const wordFamily = directChildren(
font,
WORD_NAMESPACE,
"family"
)[0];
const pitch = directChildren(
font,
WORD_NAMESPACE,
"pitch"
)[0];
const signature = directChildren(
font,
WORD_NAMESPACE,
"sig"
)[0];
const wordValue = (
element: XmlElement | undefined,
name: string
) =>
element?.getAttributeNS(WORD_NAMESPACE, name) ?? "";
if (
wordValue(panose1, "val") !== expected.panose1 ||
wordValue(charset, "val") !== expected.charset ||
wordValue(wordFamily, "val") !== expected.family ||
wordValue(pitch, "val") !== expected.pitch ||
wordValue(signature, "usb0") !==
expected.signature.usb0 ||
wordValue(signature, "usb1") !==
expected.signature.usb1 ||
wordValue(signature, "usb2") !==
expected.signature.usb2 ||
wordValue(signature, "usb3") !==
expected.signature.usb3 ||
wordValue(signature, "csb0") !==
expected.signature.csb0 ||
wordValue(signature, "csb1") !==
expected.signature.csb1
) {
throw new Error(
`word/fontTable.xml 的 ${family} 字体元数据与嵌入字形不匹配`
);
}
validatedMetadata = true;
}
usedRelationships.add(relationshipId);
referencedParts.add(relationship.targetPart);
embeddedFontCount += 1;
@@ -0,0 +1,112 @@
import type { SfntFontMetadata } from "./font-embedding.js";
export type WordFontFamily =
| "auto"
| "decorative"
| "modern"
| "roman"
| "script"
| "swiss";
export interface WordFontMetadata {
panose1: string;
charset: string;
family: WordFontFamily;
pitch: "fixed" | "variable";
signature: {
usb0: string;
usb1: string;
usb2: string;
usb3: string;
csb0: string;
csb1: string;
};
}
const codePageCharsets = [
{ bit: 18, charset: "86" },
{ bit: 20, charset: "88" },
{ bit: 17, charset: "80" },
{ bit: 19, charset: "81" },
{ bit: 21, charset: "82" },
{ bit: 16, charset: "DE" },
{ bit: 6, charset: "B2" },
{ bit: 5, charset: "B1" },
{ bit: 7, charset: "BA" },
{ bit: 2, charset: "CC" },
{ bit: 3, charset: "A1" },
{ bit: 4, charset: "A2" },
{ bit: 31, charset: "02" }
] as const;
function uint32Hex(value: number) {
return value.toString(16).toUpperCase().padStart(8, "0");
}
function byteHex(value: number) {
return value.toString(16).toUpperCase().padStart(2, "0");
}
function hasCodePageBit(
ranges: SfntFontMetadata["codePageRanges"],
bit: number
) {
const range = ranges[Math.floor(bit / 32)] ?? 0;
return ((range >>> (bit % 32)) & 1) === 1;
}
function resolveCharset(metadata: SfntFontMetadata) {
return (
codePageCharsets.find(({ bit }) =>
hasCodePageBit(metadata.codePageRanges, bit)
)?.charset ?? "00"
);
}
function resolveFamily(metadata: SfntFontMetadata): WordFontFamily {
if (metadata.fixedPitch) {
return "modern";
}
const [familyType, serifStyle] = metadata.panose;
if (familyType === 3) {
return "script";
}
if (familyType === 4) {
return "decorative";
}
if (familyType !== 2) {
return "auto";
}
if (serifStyle >= 11 && serifStyle <= 13) {
return "swiss";
}
if (
(serifStyle >= 2 && serifStyle <= 10) ||
serifStyle === 14 ||
serifStyle === 15
) {
return "roman";
}
return "auto";
}
export function createWordFontMetadata(
metadata: SfntFontMetadata
): WordFontMetadata {
const [usb0, usb1, usb2, usb3] = metadata.unicodeRanges;
const [csb0, csb1] = metadata.codePageRanges;
return {
panose1: metadata.panose.map(byteHex).join(""),
charset: resolveCharset(metadata),
family: resolveFamily(metadata),
pitch: metadata.fixedPitch ? "fixed" : "variable",
signature: {
usb0: uint32Hex(usb0),
usb1: uint32Hex(usb1),
usb2: uint32Hex(usb2),
usb3: uint32Hex(usb3),
csb0: uint32Hex(csb0),
csb1: uint32Hex(csb1)
}
};
}
@@ -39,6 +39,10 @@ describe("DOCX 字体嵌入", () => {
family: "FandolFang",
subfamily: "Regular",
postscriptName: "FandolFang-Regular",
panose: [0, 0, 5, 0, 0, 0, 0, 0, 0, 0],
unicodeRanges: [0x80000001, 0x00002000, 0x00000002, 0],
codePageRanges: [0x00060007, 0],
fixedPitch: false,
fsType: 0x0008,
permission: "editable",
noSubsetting: false,
@@ -123,6 +127,9 @@ describe("DOCX 字体嵌入", () => {
"FandolHei",
"FandolKai"
]);
expect(
prepared.map((font) => font.metadata.weightClass)
).toEqual([400, 700, 400, 400, 400]);
expect(
prepared.every((font) => font.metadata.signature === "OTTO")
).toBe(true);
@@ -97,6 +97,15 @@ describe("DOCX 字体包写入", () => {
output.entries.get("word/fontTable.xml")
);
expect(fontTable).toContain('w:name="FandolFang"');
expect(fontTable).toContain(
'<w:panose1 w:val="00000500000000000000"/>'
);
expect(fontTable).toContain('<w:charset w:val="86"/>');
expect(fontTable).toContain('<w:family w:val="auto"/>');
expect(fontTable).toContain('<w:pitch w:val="variable"/>');
expect(fontTable).toContain(
'<w:sig w:usb0="80000001" w:usb1="00002000" w:usb2="00000002" w:usb3="00000000" w:csb0="00060007" w:csb1="00000000"/>'
);
expect(fontTable).toContain("<w:embedRegular");
expect(fontTable).toContain(
`xmlns:r="${relationshipNamespace}"`
@@ -124,6 +133,22 @@ describe("DOCX 字体包写入", () => {
expect(validateGeneratedDocx(result.content)).toMatchObject({
embeddedFontCount: 1
});
const invalidEntries = new Map(output.entries);
invalidEntries.set(
"word/fontTable.xml",
encoder.encode(
fontTable.replace(
'<w:charset w:val="86"/>',
'<w:charset w:val="00"/>'
)
)
);
expect(() =>
validateGeneratedDocx(
writeGeneratedDocxPackage(invalidEntries)
)
).toThrow("字体元数据与嵌入字形不匹配");
});
it("没有字体时保持原始 DOCX 字节不变", () => {
@@ -279,6 +279,15 @@ describe("动态 reference.docx", () => {
alignment: "center",
keepWithNext: true
}
},
{
slot: "official-edition",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
lineSpacing: 1.5
}
}
])
}
@@ -300,6 +309,13 @@ describe("动态 reference.docx", () => {
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('<w:jc w:val="both"/>');
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
'w:line="504" w:lineRule="exact"'
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
);
expect(stylesXml).not.toContain('w:lineRule="auto"');
expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"');
expect(fontTableXml).toContain(
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { SfntFontMetadata } from "../src/font-embedding.js";
import { createWordFontMetadata } from "../src/word-font-metadata.js";
function metadata(
overrides: Partial<SfntFontMetadata> = {}
): SfntFontMetadata {
return {
family: "Test Font",
subfamily: "Regular",
weightClass: 400,
panose: [2, 2, 5, 3, 5, 4, 5, 2, 3, 4],
unicodeRanges: [
0x20000083,
0x2adf3c10,
0x00000016,
0
],
codePageRanges: [0x60060107, 0],
fixedPitch: false,
fsType: 0,
permission: "installable",
noSubsetting: false,
signature: "\u0000\u0001\u0000\u0000",
...overrides
};
}
describe("Word 字体元数据映射", () => {
it("将 CJK Serif 的 OS/2 范围映射为简体中文 Word 字体签名", () => {
expect(createWordFontMetadata(metadata())).toEqual({
panose1: "02020503050405020304",
charset: "86",
family: "roman",
pitch: "variable",
signature: {
usb0: "20000083",
usb1: "2ADF3C10",
usb2: "00000016",
usb3: "00000000",
csb0: "60060107",
csb1: "00000000"
}
});
});
it("根据 Panose 与固定字距通用推导 Word 字体族", () => {
expect(
createWordFontMetadata(
metadata({
panose: [2, 11, 5, 3, 5, 4, 5, 2, 3, 4]
})
).family
).toBe("swiss");
expect(
createWordFontMetadata(
metadata({
panose: [3, 0, 5, 3, 5, 4, 5, 2, 3, 4]
})
).family
).toBe("script");
expect(
createWordFontMetadata(
metadata({
fixedPitch: true
})
)
).toMatchObject({
family: "modern",
pitch: "fixed"
});
});
it("在没有 CJK code-page 位时选择对应 charset 或默认 ANSI", () => {
expect(
createWordFontMetadata(
metadata({ codePageRanges: [1 << 2, 0] })
).charset
).toBe("CC");
expect(
createWordFontMetadata(
metadata({ codePageRanges: [0, 0] })
).charset
).toBe("00");
});
});