新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
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: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}"`
|
|
);
|
|
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
|
|
});
|
|
|
|
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("字体元数据与嵌入字形不匹配");
|
|
}, 15_000);
|
|
|
|
it("没有字体时保持原始 DOCX 字节不变", () => {
|
|
const input = createTestBaselineReference();
|
|
const result = embedFontsInGeneratedDocx(input, []);
|
|
expect(result.content).toBe(input);
|
|
expect(result.report.embeddedFaceCount).toBe(0);
|
|
});
|
|
});
|