feat: 实现 DOCX 可编辑字体嵌入
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
obfuscateDocxFont,
|
||||
prepareDocxFont,
|
||||
prepareDocxFonts,
|
||||
readSfntFontMetadata,
|
||||
resolveFontEmbeddingPermission,
|
||||
xorDocxFont
|
||||
} from "../src/font-embedding.js";
|
||||
|
||||
const fandolFangUrl = new URL(
|
||||
"../../../themes/_shared/official-fonts/FandolFang-Regular.woff2",
|
||||
import.meta.url
|
||||
);
|
||||
const fandolFaces = [
|
||||
["FandolSong", "FandolSong-Regular.woff2", 400],
|
||||
["FandolSong", "FandolSong-Bold.woff2", 700],
|
||||
["FandolFang", "FandolFang-Regular.woff2", 400],
|
||||
["FandolHei", "FandolHei-Regular.woff2", 400],
|
||||
["FandolKai", "FandolKai-Regular.woff2", 400]
|
||||
] as const;
|
||||
|
||||
describe("DOCX 字体嵌入", () => {
|
||||
it("在纯 Node.js 中将 WOFF2 转为可编辑嵌入的 OpenType SFNT", async () => {
|
||||
const content = await readFile(fandolFangUrl);
|
||||
const prepared = await prepareDocxFont({
|
||||
family: "Mdpdf Fandol Fang",
|
||||
aliases: ["FangSong", "STFangsong"],
|
||||
source:
|
||||
"theme-shared:official-fonts/FandolFang-Regular.woff2",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
license: "GPL-3.0-with-font-exception",
|
||||
content
|
||||
});
|
||||
|
||||
expect(prepared.metadata).toMatchObject({
|
||||
family: "FandolFang",
|
||||
subfamily: "Regular",
|
||||
postscriptName: "FandolFang-Regular",
|
||||
fsType: 0x0008,
|
||||
permission: "editable",
|
||||
noSubsetting: false,
|
||||
signature: "OTTO"
|
||||
});
|
||||
expect(prepared.sfnt.byteLength).toBeGreaterThan(
|
||||
content.byteLength
|
||||
);
|
||||
expect(
|
||||
readSfntFontMetadata(prepared.sfnt)
|
||||
).toEqual(prepared.metadata);
|
||||
});
|
||||
|
||||
it("使用稳定字体键混淆前 32 字节并可按同一算法还原", async () => {
|
||||
const prepared = await prepareDocxFont({
|
||||
family: "Mdpdf Fandol Fang",
|
||||
aliases: [],
|
||||
source:
|
||||
"theme-shared:official-fonts/FandolFang-Regular.woff2",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
license: "GPL-3.0-with-font-exception",
|
||||
content: await readFile(fandolFangUrl)
|
||||
});
|
||||
|
||||
const first = obfuscateDocxFont(prepared);
|
||||
const second = obfuscateDocxFont(prepared);
|
||||
expect(first.fontKey).toBe(second.fontKey);
|
||||
expect(first.partName).toBe(second.partName);
|
||||
expect(
|
||||
Buffer.compare(
|
||||
Buffer.from(first.content),
|
||||
Buffer.from(second.content)
|
||||
)
|
||||
).toBe(0);
|
||||
expect(first.fontKey).toMatch(
|
||||
/^\{[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}\}$/u
|
||||
);
|
||||
expect(first.partName).toMatch(/^word\/fonts\/.+\.odttf$/u);
|
||||
expect(first.content.subarray(0, 32)).not.toEqual(
|
||||
prepared.sfnt.subarray(0, 32)
|
||||
);
|
||||
expect(
|
||||
Buffer.compare(
|
||||
Buffer.from(
|
||||
xorDocxFont(first.content, first.fontKey)
|
||||
),
|
||||
Buffer.from(prepared.sfnt)
|
||||
)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("串行调度底层 WASM 并安全准备多个并发 WOFF2 字形", async () => {
|
||||
const prepared = await prepareDocxFonts(
|
||||
await Promise.all(
|
||||
fandolFaces.map(
|
||||
async ([family, fileName, weight]) => ({
|
||||
family,
|
||||
aliases: [],
|
||||
source: `theme-shared:official-fonts/${fileName}`,
|
||||
weight,
|
||||
style: "normal" as const,
|
||||
license: "GPL-3.0-with-font-exception",
|
||||
content: await readFile(
|
||||
new URL(
|
||||
`../../../themes/_shared/official-fonts/${fileName}`,
|
||||
import.meta.url
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
expect(prepared).toHaveLength(5);
|
||||
expect(
|
||||
prepared.map((font) => font.metadata.family)
|
||||
).toEqual([
|
||||
"FandolSong",
|
||||
"FandolSong",
|
||||
"FandolFang",
|
||||
"FandolHei",
|
||||
"FandolKai"
|
||||
]);
|
||||
expect(
|
||||
prepared.every((font) => font.metadata.signature === "OTTO")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("只接受可安装或可编辑嵌入权限", () => {
|
||||
expect(resolveFontEmbeddingPermission(0)).toBe("installable");
|
||||
expect(resolveFontEmbeddingPermission(0x0008)).toBe("editable");
|
||||
expect(() => resolveFontEmbeddingPermission(0x0002)).toThrow(
|
||||
"禁止嵌入"
|
||||
);
|
||||
expect(() => resolveFontEmbeddingPermission(0x0004)).toThrow(
|
||||
"仅允许预览和打印"
|
||||
);
|
||||
expect(() => resolveFontEmbeddingPermission(0x0200)).toThrow(
|
||||
"位图"
|
||||
);
|
||||
expect(() => resolveFontEmbeddingPermission(0x000c)).toThrow(
|
||||
"组合无效"
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝损坏或非 SFNT 字体", () => {
|
||||
expect(() =>
|
||||
readSfntFontMetadata(new Uint8Array(64))
|
||||
).toThrow("TrueType 或 OpenType");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareDocxFont, xorDocxFont } from "../src/font-embedding.js";
|
||||
import { embedFontsInGeneratedDocx } from "../src/font-package-transform.js";
|
||||
import {
|
||||
readGeneratedDocxPackage,
|
||||
writeGeneratedDocxPackage
|
||||
} from "../src/reference-package.js";
|
||||
import { validateGeneratedDocx } from "../src/validator.js";
|
||||
import { createTestBaselineReference } from "./reference-test-fixture.js";
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const wordNamespace =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const relationshipNamespace =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const fandolFangUrl = new URL(
|
||||
"../../../themes/_shared/official-fonts/FandolFang-Regular.woff2",
|
||||
import.meta.url
|
||||
);
|
||||
|
||||
describe("DOCX 字体包写入", () => {
|
||||
it("写入 ODTTF、字体关系和内容类型并替换逻辑字体名", async () => {
|
||||
const source = readGeneratedDocxPackage(
|
||||
createTestBaselineReference()
|
||||
);
|
||||
const entries = new Map(source.entries);
|
||||
const originalStyles = decoder.decode(
|
||||
entries.get("word/styles.xml")
|
||||
);
|
||||
entries.set(
|
||||
"word/styles.xml",
|
||||
encoder.encode(
|
||||
originalStyles.replace(
|
||||
"<w:rPr/>",
|
||||
'<w:rPr><w:rFonts w:ascii="Mdpdf Fandol Fang" w:hAnsi="Mdpdf Fandol Fang" w:eastAsia="FangSong"/></w:rPr>'
|
||||
).replace(
|
||||
"</w:styles>",
|
||||
'<w:style w:type="paragraph" w:styleId="SourceCode"><w:name w:val="Source Code"/></w:style></w:styles>'
|
||||
)
|
||||
)
|
||||
);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(
|
||||
decoder
|
||||
.decode(entries.get("word/document.xml"))
|
||||
.replace(
|
||||
"<w:footnotePr/>",
|
||||
'<w:footnotePr/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>'
|
||||
)
|
||||
)
|
||||
);
|
||||
const input = writeGeneratedDocxPackage(entries);
|
||||
const font = await prepareDocxFont({
|
||||
family: "Mdpdf Fandol Fang",
|
||||
aliases: ["FangSong", "STFangsong"],
|
||||
source:
|
||||
"theme-shared:official-fonts/FandolFang-Regular.woff2",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
license: "GPL-3.0-with-font-exception",
|
||||
content: await readFile(fandolFangUrl)
|
||||
});
|
||||
|
||||
const result = embedFontsInGeneratedDocx(input, [font]);
|
||||
const output = readGeneratedDocxPackage(result.content);
|
||||
const fontParts = [...output.entries.keys()].filter((name) =>
|
||||
/^word\/fonts\/.+\.odttf$/u.test(name)
|
||||
);
|
||||
expect(result.report).toMatchObject({
|
||||
embeddedFaceCount: 1,
|
||||
embeddedFamilyCount: 1,
|
||||
replacedFontReferenceCount: 3,
|
||||
totalSfntBytes: font.sfnt.byteLength
|
||||
});
|
||||
expect(fontParts).toHaveLength(1);
|
||||
|
||||
const contentTypes = decoder.decode(
|
||||
output.entries.get("[Content_Types].xml")
|
||||
);
|
||||
expect(contentTypes).toContain(
|
||||
'Extension="odttf" ContentType="application/vnd.openxmlformats-officedocument.obfuscatedFont"'
|
||||
);
|
||||
const relationships = decoder.decode(
|
||||
output.entries.get("word/_rels/fontTable.xml.rels")
|
||||
);
|
||||
expect(relationships).toContain(
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"'
|
||||
);
|
||||
expect(relationships).toContain(
|
||||
`Target="${fontParts[0]!.replace(/^word\//u, "")}"`
|
||||
);
|
||||
|
||||
const fontTable = decoder.decode(
|
||||
output.entries.get("word/fontTable.xml")
|
||||
);
|
||||
expect(fontTable).toContain('w:name="FandolFang"');
|
||||
expect(fontTable).toContain("<w:embedRegular");
|
||||
expect(fontTable).toContain(
|
||||
`xmlns:r="${relationshipNamespace}"`
|
||||
);
|
||||
const fontKey = /w:fontKey="([^"]+)"/u.exec(fontTable)?.[1];
|
||||
expect(fontKey).toBeTruthy();
|
||||
expect(
|
||||
Buffer.compare(
|
||||
Buffer.from(
|
||||
xorDocxFont(
|
||||
output.entries.get(fontParts[0]!)!,
|
||||
fontKey!
|
||||
)
|
||||
),
|
||||
Buffer.from(font.sfnt)
|
||||
)
|
||||
).toBe(0);
|
||||
|
||||
const styles = decoder.decode(
|
||||
output.entries.get("word/styles.xml")
|
||||
);
|
||||
expect(styles).not.toContain("Mdpdf Fandol Fang");
|
||||
expect(styles).not.toContain("FangSong");
|
||||
expect(styles.match(/FandolFang/gu)).toHaveLength(3);
|
||||
expect(validateGeneratedDocx(result.content)).toMatchObject({
|
||||
embeddedFontCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||
const input = createTestBaselineReference();
|
||||
const result = embedFontsInGeneratedDocx(input, []);
|
||||
expect(result.content).toBe(input);
|
||||
expect(result.report.embeddedFaceCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ function input() {
|
||||
exportConfig: defaultExportConfig,
|
||||
theme: theme(),
|
||||
themeTokens,
|
||||
fonts: [],
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "测试人",
|
||||
|
||||
Reference in New Issue
Block a user