feat: 实现 DOCX 可编辑字体嵌入

This commit is contained in:
SkyJourney
2026-07-31 16:18:30 +08:00
parent f3847f3e4b
commit 10bc62cee4
32 changed files with 2195 additions and 76 deletions
+2 -1
View File
@@ -29,7 +29,8 @@
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@xmldom/xmldom": "0.9.10",
"fflate": "0.8.3"
"fflate": "0.8.3",
"fontverter": "2.0.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
@@ -31,6 +31,7 @@ const themeTokenReportPath = path.join(
"snapshots.json"
);
const decoder = new TextDecoder();
const sharedFontSourcePrefix = "theme-shared:";
const placeholderPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
@@ -104,6 +105,33 @@ function readThemeManifests() {
);
}
function readThemeFonts(theme) {
return (theme.docxFonts?.faces ?? []).map((face) => {
const shared = face.source.startsWith(sharedFontSourcePrefix);
const relativePath = shared
? path.join(
"_shared",
face.source.slice(sharedFontSourcePrefix.length)
)
: path.join(theme.id, face.source);
const sourcePath = path.resolve(themesDirectory, relativePath);
const relativeSourcePath = path.relative(
themesDirectory,
sourcePath
);
assert(
relativeSourcePath !== ".." &&
!relativeSourcePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativeSourcePath),
`字体资源路径越界:${face.source}`
);
return {
...face,
content: fs.readFileSync(sourcePath)
};
});
}
function structuralValues(document) {
if (!document) {
return [];
@@ -393,6 +421,7 @@ for (const theme of themes) {
themeTokens,
metadata: rendered.metadata,
semanticDocument: rendered.semanticDocument,
fonts: readThemeFonts(theme),
media: createMedia(markdown)
});
} catch (error) {
+520
View File
@@ -0,0 +1,520 @@
import { createHash } from "node:crypto";
import {
MAXIMUM_DOCX_FONT_FACE_COUNT,
MAXIMUM_DOCX_FONT_SOURCE_BYTES,
MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES
} from "@md-to-pdf/core";
import fontverter from "fontverter";
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 fontConversionCache = new Map<
string,
Promise<Uint8Array>
>();
let fontConversionQueue = Promise.resolve();
const supportedSfntSignatures = new Set([
"\u0000\u0001\u0000\u0000",
"OTTO",
"true"
]);
export type DocxFontStyle = "normal" | "italic";
export type DocxFontEmbeddingPermission =
| "installable"
| "editable";
export interface DocxFontSource {
family: string;
aliases: string[];
source: string;
weight: number;
style: DocxFontStyle;
license: string;
content: Uint8Array;
}
export interface SfntFontMetadata {
family: string;
subfamily: string;
postscriptName?: string;
weightClass: number;
fsType: number;
permission: DocxFontEmbeddingPermission;
noSubsetting: boolean;
signature: "\u0000\u0001\u0000\u0000" | "OTTO" | "true";
}
export interface PreparedDocxFont {
source: DocxFontSource;
metadata: SfntFontMetadata;
sfnt: Uint8Array;
fingerprint: string;
}
export interface ObfuscatedDocxFont {
content: Uint8Array;
fontKey: string;
partName: string;
}
interface SfntTable {
offset: number;
length: number;
}
function readTag(content: Uint8Array, offset: number) {
return String.fromCharCode(
content[offset] ?? 0,
content[offset + 1] ?? 0,
content[offset + 2] ?? 0,
content[offset + 3] ?? 0
);
}
function assertRange(
content: Uint8Array,
offset: number,
length: number,
label: string
) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset + length > content.byteLength
) {
throw new Error(`字体 ${label} 数据越界`);
}
}
function readUint16(
view: DataView,
content: Uint8Array,
offset: number,
label: string
) {
assertRange(content, offset, 2, label);
return view.getUint16(offset, false);
}
function readUint32(
view: DataView,
content: Uint8Array,
offset: number,
label: string
) {
assertRange(content, offset, 4, label);
return view.getUint32(offset, false);
}
function readSfntTables(content: Uint8Array) {
if (
content.byteLength < 12 ||
content.byteLength > MAXIMUM_DOCX_FONT_SFNT_BYTES
) {
throw new Error("SFNT 字体大小无效");
}
const signature = readTag(content, 0);
if (!supportedSfntSignatures.has(signature)) {
throw new Error("字体不是受支持的 TrueType 或 OpenType SFNT");
}
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const tableCount = readUint16(
view,
content,
4,
"表目录数量"
);
if (tableCount < 1 || tableCount > 256) {
throw new Error("SFNT 字体表数量无效");
}
assertRange(content, 12, tableCount * 16, "表目录");
const tables = new Map<string, SfntTable>();
for (let index = 0; index < tableCount; index += 1) {
const recordOffset = 12 + index * 16;
const tag = readTag(content, recordOffset);
const offset = readUint32(
view,
content,
recordOffset + 8,
`${tag} 表偏移`
);
const length = readUint32(
view,
content,
recordOffset + 12,
`${tag} 表长度`
);
assertRange(content, offset, length, `${tag}`);
if (tables.has(tag)) {
throw new Error(`SFNT 字体包含重复表:${tag}`);
}
tables.set(tag, { offset, length });
}
return {
signature: signature as SfntFontMetadata["signature"],
tables,
view
};
}
function decodeUtf16Be(content: Uint8Array) {
if (content.byteLength % 2 !== 0) {
throw new Error("字体名称 UTF-16BE 长度无效");
}
let value = "";
for (let offset = 0; offset < content.byteLength; offset += 2) {
value += String.fromCharCode(
((content[offset] ?? 0) << 8) |
(content[offset + 1] ?? 0)
);
}
return value.replaceAll("\0", "").trim();
}
function decodeName(
content: Uint8Array,
platformId: number
) {
if (platformId === 0 || platformId === 3) {
return decodeUtf16Be(content);
}
return new TextDecoder("windows-1252")
.decode(content)
.replaceAll("\0", "")
.trim();
}
function readFontName(
content: Uint8Array,
table: SfntTable,
nameIds: readonly number[]
) {
if (table.length < 6) {
throw new Error("字体 name 表不完整");
}
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const count = readUint16(
view,
content,
table.offset + 2,
"name 记录数量"
);
const stringsOffset = readUint16(
view,
content,
table.offset + 4,
"name 字符串偏移"
);
assertRange(content, table.offset + 6, count * 12, "name 记录");
const candidates: Array<{
value: string;
priority: number;
}> = [];
for (let index = 0; index < count; index += 1) {
const offset = table.offset + 6 + index * 12;
const platformId = readUint16(
view,
content,
offset,
"name platformID"
);
const languageId = readUint16(
view,
content,
offset + 4,
"name languageID"
);
const nameId = readUint16(
view,
content,
offset + 6,
"name nameID"
);
const requestedIndex = nameIds.indexOf(nameId);
if (requestedIndex < 0) {
continue;
}
const length = readUint16(
view,
content,
offset + 8,
"name 字符串长度"
);
const relativeOffset = readUint16(
view,
content,
offset + 10,
"name 字符串位置"
);
const valueOffset =
table.offset + stringsOffset + relativeOffset;
assertRange(content, valueOffset, length, "name 字符串");
const value = decodeName(
content.subarray(valueOffset, valueOffset + length),
platformId
);
if (!value) {
continue;
}
const platformPriority =
platformId === 3 ? 30 : platformId === 0 ? 20 : 10;
const languagePriority =
languageId === 0x0409 ? 2 : languageId === 0 ? 1 : 0;
candidates.push({
value,
priority:
(nameIds.length - requestedIndex) * 100 +
platformPriority +
languagePriority
});
}
return candidates.sort(
(left, right) => right.priority - left.priority
)[0]?.value;
}
export function resolveFontEmbeddingPermission(fsType: number) {
if (
!Number.isInteger(fsType) ||
fsType < 0 ||
fsType > 0xffff
) {
throw new Error("字体 fsType 无效");
}
if ((fsType & 0xfef0) !== 0) {
throw new Error(
`字体 fsType 包含不支持的保留或位图嵌入标志:0x${fsType
.toString(16)
.padStart(4, "0")}`
);
}
const usage = fsType & 0x000f;
if (usage === 0) {
return "installable" satisfies DocxFontEmbeddingPermission;
}
if (usage === 0x0008) {
return "editable" satisfies DocxFontEmbeddingPermission;
}
if (usage === 0x0002) {
throw new Error("字体许可证禁止嵌入");
}
if (usage === 0x0004) {
throw new Error("字体仅允许预览和打印嵌入,不能用于可编辑 DOCX");
}
throw new Error(
`字体 fsType 使用权限组合无效:0x${usage
.toString(16)
.padStart(4, "0")}`
);
}
export function readSfntFontMetadata(
content: Uint8Array
): SfntFontMetadata {
const { signature, tables, view } = readSfntTables(content);
const os2 = tables.get("OS/2");
const name = tables.get("name");
if (!os2 || os2.length < 10) {
throw new Error("SFNT 字体缺少完整 OS/2 表");
}
if (!name) {
throw new Error("SFNT 字体缺少 name 表");
}
const family = readFontName(content, name, [16, 1]);
const subfamily = readFontName(content, name, [17, 2]);
if (!family || !subfamily) {
throw new Error("SFNT 字体缺少家族或字形面名称");
}
const fsType = readUint16(
view,
content,
os2.offset + 8,
"OS/2 fsType"
);
const permission = resolveFontEmbeddingPermission(fsType);
const postscriptName = readFontName(content, name, [6]);
return {
family,
subfamily,
...(postscriptName ? { postscriptName } : {}),
weightClass: readUint16(
view,
content,
os2.offset + 4,
"OS/2 usWeightClass"
),
fsType,
permission,
noSubsetting: (fsType & 0x0100) !== 0,
signature
};
}
export async function prepareDocxFont(
source: DocxFontSource
): Promise<PreparedDocxFont> {
if (
source.content.byteLength < 32 ||
source.content.byteLength > MAXIMUM_DOCX_FONT_SOURCE_BYTES
) {
throw new Error(`字体资源大小无效:${source.source}`);
}
const sourceFingerprint = createHash("sha256")
.update(source.content)
.digest("hex");
let conversion = fontConversionCache.get(sourceFingerprint);
if (!conversion) {
if (
fontConversionCache.size >=
MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES
) {
const oldest = fontConversionCache.keys().next().value;
if (oldest !== undefined) {
fontConversionCache.delete(oldest);
}
}
const sourceContent = Buffer.from(source.content);
conversion = fontConversionQueue
.then(() => fontverter.convert(sourceContent, "sfnt"))
.then((converted) => {
if (
converted.byteLength < 32 ||
converted.byteLength > MAXIMUM_DOCX_FONT_SFNT_BYTES
) {
throw new Error(
`字体解码后大小超过限制:${source.source}`
);
}
return new Uint8Array(converted);
});
fontConversionQueue = conversion.then(
() => undefined,
() => undefined
);
fontConversionCache.set(sourceFingerprint, conversion);
void conversion.catch(() => {
if (
fontConversionCache.get(sourceFingerprint) === conversion
) {
fontConversionCache.delete(sourceFingerprint);
}
});
}
const sfnt = (await conversion).slice();
const metadata = readSfntFontMetadata(sfnt);
const fingerprint = createHash("sha256")
.update(sfnt)
.digest("hex");
return {
source,
metadata,
sfnt,
fingerprint
};
}
export async function prepareDocxFonts(
sources: readonly DocxFontSource[]
) {
if (sources.length > MAXIMUM_DOCX_FONT_FACE_COUNT) {
throw new Error("DOCX 字体字形面数量超过限制");
}
const totalSourceBytes = sources.reduce(
(total, source) => total + source.content.byteLength,
0
);
if (totalSourceBytes > MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES) {
throw new Error("DOCX 字体资源总大小超过限制");
}
const fonts = await Promise.all(sources.map(prepareDocxFont));
const totalSfntBytes = fonts.reduce(
(total, font) => total + font.sfnt.byteLength,
0
);
if (totalSfntBytes > MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES) {
throw new Error("DOCX 字体解码后总大小超过限制");
}
return fonts;
}
function formatGuid(bytes: Uint8Array) {
const hex = Buffer.from(bytes).toString("hex").toUpperCase();
return `{${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
12,
16
)}-${hex.slice(16, 20)}-${hex.slice(20)}}`;
}
function fontKeyBytes(fontKey: string) {
const hex = fontKey.replace(/[{}-]/gu, "");
if (!/^[0-9a-f]{32}$/iu.test(hex)) {
throw new Error("DOCX 字体混淆键无效");
}
return Uint8Array.from(
Array.from({ length: 16 }, (_, index) =>
Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
)
).reverse();
}
export function xorDocxFont(
content: Uint8Array,
fontKey: string
) {
if (content.byteLength < 32) {
throw new Error("待混淆字体内容不足 32 字节");
}
const result = content.slice();
const key = fontKeyBytes(fontKey);
for (let index = 0; index < 32; index += 1) {
result[index] =
(result[index] ?? 0) ^ (key[index % key.length] ?? 0);
}
return result;
}
export function obfuscateDocxFont(
font: PreparedDocxFont
): ObfuscatedDocxFont {
const keyBytes = createHash("sha256")
.update(font.sfnt)
.update("\0", "utf8")
.update(font.source.family, "utf8")
.update("\0", "utf8")
.update(String(font.source.weight), "utf8")
.update("\0", "utf8")
.update(font.source.style, "utf8")
.digest()
.subarray(0, 16);
keyBytes[6] = ((keyBytes[6] ?? 0) & 0x0f) | 0x40;
keyBytes[8] = ((keyBytes[8] ?? 0) & 0x3f) | 0x80;
const fontKey = formatGuid(keyBytes);
const partStem = fontKey.slice(1, -1).toLowerCase();
return {
content: xorDocxFont(font.sfnt, fontKey),
fontKey,
partName: `word/fonts/${partStem}.odttf`
};
}
@@ -0,0 +1,417 @@
import {
CONTENT_TYPES_NAMESPACE,
DRAWING_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
appendElement,
directChildren,
parseXmlPart,
removeDirectChildren,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import {
obfuscateDocxFont,
type PreparedDocxFont
} from "./font-embedding.js";
import {
readGeneratedDocxPackage,
writeGeneratedDocxPackage
} from "./reference-package.js";
const FONT_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
const OBFUSCATED_FONT_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.obfuscatedFont";
const XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/";
export interface DocxFontEmbeddingReport {
embeddedFaceCount: number;
embeddedFamilyCount: number;
replacedFontReferenceCount: number;
totalSfntBytes: number;
}
export interface DocxFontEmbeddingResult {
content: Uint8Array;
report: DocxFontEmbeddingReport;
}
type EmbeddedFontRole =
| "embedRegular"
| "embedBold"
| "embedItalic"
| "embedBoldItalic";
interface EmbeddedFace {
font: PreparedDocxFont;
role: EmbeddedFontRole;
relationshipId: string;
fontKey: string;
partName: string;
content: Uint8Array;
}
function normalizeFontName(value: string) {
return value.trim().toLocaleLowerCase("en-US");
}
function fontRole(font: PreparedDocxFont): EmbeddedFontRole {
const bold = font.source.weight >= 600;
const italic = font.source.style === "italic";
if (bold && italic) {
return "embedBoldItalic";
}
if (bold) {
return "embedBold";
}
if (italic) {
return "embedItalic";
}
return "embedRegular";
}
function createFontLookup(fonts: readonly PreparedDocxFont[]) {
const lookup = new Map<string, PreparedDocxFont>();
for (const font of fonts) {
for (const name of [
font.metadata.family,
font.source.family,
...font.source.aliases
]) {
const normalized = normalizeFontName(name);
if (!lookup.has(normalized)) {
lookup.set(normalized, font);
}
}
}
return lookup;
}
function replaceFontName(
value: string,
lookup: ReadonlyMap<string, PreparedDocxFont>
) {
return lookup.get(normalizeFontName(value))?.metadata.family;
}
function rewriteWordFontReferences(
entries: Map<string, Uint8Array>,
lookup: ReadonlyMap<string, PreparedDocxFont>
) {
let count = 0;
for (const [partName, content] of entries) {
if (
!/^word\/.+\.xml$/u.test(partName) ||
partName === "word/fontTable.xml"
) {
continue;
}
const document = parseXmlPart(content, partName);
let changed = false;
for (const fonts of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "rFonts")
)) {
for (const attribute of [
"ascii",
"hAnsi",
"eastAsia",
"cs"
]) {
const value = fonts.getAttributeNS(
WORD_NAMESPACE,
attribute
);
if (!value) {
continue;
}
const replacement = replaceFontName(value, lookup);
if (!replacement || replacement === value) {
continue;
}
fonts.setAttributeNS(
WORD_NAMESPACE,
`w:${attribute}`,
replacement
);
count += 1;
changed = true;
}
}
for (const element of Array.from(
document.getElementsByTagNameNS("*", "*")
)) {
if (
element.namespaceURI !== DRAWING_NAMESPACE ||
!element.hasAttribute("typeface")
) {
continue;
}
const value = element.getAttribute("typeface");
if (!value) {
continue;
}
const replacement = replaceFontName(value, lookup);
if (!replacement || replacement === value) {
continue;
}
element.setAttribute("typeface", replacement);
count += 1;
changed = true;
}
if (changed) {
entries.set(partName, serializeXmlPart(document));
}
}
return count;
}
function nextRelationshipId(root: XmlElement) {
const used = new Set(
Array.from(
root.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
).map((relationship) => relationship.getAttribute("Id") ?? "")
);
let next = 1;
return () => {
while (used.has(`rId${next}`)) {
next += 1;
}
const value = `rId${next}`;
used.add(value);
next += 1;
return value;
};
}
function prepareEmbeddedFaces(
fonts: readonly PreparedDocxFont[],
relationships: XmlElement
) {
const allocateId = nextRelationshipId(relationships);
const roles = new Map<string, string>();
const faces: EmbeddedFace[] = [];
for (const font of [...fonts].sort((left, right) => {
const family = left.metadata.family.localeCompare(
right.metadata.family
);
return (
family ||
left.source.weight - right.source.weight ||
left.source.style.localeCompare(right.source.style)
);
})) {
const role = fontRole(font);
const roleKey = `${normalizeFontName(
font.metadata.family
)}\0${role}`;
const existingFingerprint = roles.get(roleKey);
if (existingFingerprint) {
if (existingFingerprint !== font.fingerprint) {
throw new Error(
`字体 ${font.metadata.family}${role} 字形面重复`
);
}
continue;
}
roles.set(roleKey, font.fingerprint);
const obfuscated = obfuscateDocxFont(font);
faces.push({
font,
role,
relationshipId: allocateId(),
...obfuscated
});
}
return faces;
}
function createRelationshipsDocument() {
return parseXmlPart(
new TextEncoder().encode(
`<Relationships xmlns="${PACKAGE_RELATIONSHIP_NAMESPACE}"/>`
),
"word/_rels/fontTable.xml.rels"
);
}
function updateFontRelationships(
entries: Map<string, Uint8Array>,
fonts: readonly PreparedDocxFont[]
) {
const partName = "word/_rels/fontTable.xml.rels";
const document = entries.has(partName)
? parseXmlPart(entries.get(partName)!, partName)
: createRelationshipsDocument();
const root = document.documentElement!;
if (
root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE ||
root.localName !== "Relationships"
) {
throw new Error("fontTable.xml.rels 根元素无效");
}
for (const relationship of Array.from(
root.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
if (relationship.getAttribute("Type") === FONT_RELATIONSHIP_TYPE) {
root.removeChild(relationship);
}
}
const faces = prepareEmbeddedFaces(fonts, root);
for (const face of faces) {
appendElement(
root,
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship",
{
Id: face.relationshipId,
Type: FONT_RELATIONSHIP_TYPE,
Target: face.partName.replace(/^word\//u, "")
}
);
}
entries.set(partName, serializeXmlPart(document));
return faces;
}
function updateFontTable(
entries: Map<string, Uint8Array>,
faces: readonly EmbeddedFace[]
) {
const partName = "word/fontTable.xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const root = document.documentElement!;
if (
root.namespaceURI !== WORD_NAMESPACE ||
root.localName !== "fonts"
) {
throw new Error("word/fontTable.xml 根元素无效");
}
root.setAttributeNS(
XMLNS_NAMESPACE,
"xmlns:r",
OFFICE_RELATIONSHIP_NAMESPACE
);
for (const font of directChildren(root, WORD_NAMESPACE, "font")) {
removeDirectChildren(
font,
WORD_NAMESPACE,
"embedRegular",
"embedBold",
"embedItalic",
"embedBoldItalic"
);
}
const byName = new Map(
directChildren(root, WORD_NAMESPACE, "font").map((font) => [
normalizeFontName(
font.getAttributeNS(WORD_NAMESPACE, "name") ?? ""
),
font
])
);
for (const face of faces) {
const family = face.font.metadata.family;
let font = byName.get(normalizeFontName(family));
if (!font) {
font = appendElement(root, WORD_NAMESPACE, "w:font", {
"w:name": family
});
appendElement(font, WORD_NAMESPACE, "w:family", {
"w:val": "auto"
});
byName.set(normalizeFontName(family), font);
}
appendElement(font, WORD_NAMESPACE, `w:${face.role}`, {
"r:id": face.relationshipId,
"w:fontKey": face.fontKey
});
}
entries.set(partName, serializeXmlPart(document));
}
function updateContentTypes(entries: Map<string, Uint8Array>) {
const partName = "[Content_Types].xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const root = document.documentElement!;
if (
root.namespaceURI !== CONTENT_TYPES_NAMESPACE ||
root.localName !== "Types"
) {
throw new Error("[Content_Types].xml 根元素无效");
}
for (const entry of Array.from(
root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Default")
)) {
if (
(entry.getAttribute("Extension") ?? "").toLowerCase() ===
"odttf"
) {
root.removeChild(entry);
}
}
appendElement(root, CONTENT_TYPES_NAMESPACE, "Default", {
Extension: "odttf",
ContentType: OBFUSCATED_FONT_CONTENT_TYPE
});
entries.set(partName, serializeXmlPart(document));
}
function removeExistingFontParts(entries: Map<string, Uint8Array>) {
for (const partName of [...entries.keys()]) {
if (/^word\/fonts\//u.test(partName)) {
entries.delete(partName);
}
}
}
export function embedFontsInGeneratedDocx(
content: Uint8Array,
fonts: readonly PreparedDocxFont[]
): DocxFontEmbeddingResult {
if (fonts.length === 0) {
return {
content,
report: {
embeddedFaceCount: 0,
embeddedFamilyCount: 0,
replacedFontReferenceCount: 0,
totalSfntBytes: 0
}
};
}
const source = readGeneratedDocxPackage(content);
const entries = new Map(source.entries);
removeExistingFontParts(entries);
const lookup = createFontLookup(fonts);
const replacedFontReferenceCount =
rewriteWordFontReferences(entries, lookup);
const faces = updateFontRelationships(entries, fonts);
updateFontTable(entries, faces);
updateContentTypes(entries);
for (const face of faces) {
entries.set(face.partName, face.content);
}
return {
content: writeGeneratedDocxPackage(entries),
report: {
embeddedFaceCount: faces.length,
embeddedFamilyCount: new Set(
faces.map((face) => face.font.metadata.family)
).size,
replacedFontReferenceCount,
totalSfntBytes: faces.reduce(
(total, face) => total + face.font.sfnt.byteLength,
0
)
}
};
}
+15
View File
@@ -0,0 +1,15 @@
declare module "fontverter" {
export type FontFormat = "sfnt" | "woff" | "woff2";
export interface Fontverter {
convert(
content: Buffer,
targetFormat: FontFormat,
sourceFormat?: FontFormat
): Promise<Buffer>;
detectFormat(content: Buffer): FontFormat;
}
const fontverter: Fontverter;
export default fontverter;
}
+2
View File
@@ -1,5 +1,7 @@
export * from "./acceptance-validator.js";
export * from "./document-structure-transform.js";
export * from "./font-embedding.js";
export * from "./font-package-transform.js";
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./pandoc-process.js";
+12 -1
View File
@@ -28,6 +28,11 @@ import {
type DynamicReferenceDocxResult
} from "./reference-builder.js";
import { finalizeGeneratedDocxStructure } from "./document-structure-transform.js";
import {
prepareDocxFonts,
type DocxFontSource
} from "./font-embedding.js";
import { embedFontsInGeneratedDocx } from "./font-package-transform.js";
import type { DynamicReferenceDocxOptions } from "./reference-options.js";
import { preparePandocMedia } from "./pandoc-media.js";
import { createPandocStructurePlan } from "./pandoc-structure.js";
@@ -67,6 +72,7 @@ export interface PandocDocxConversionInput {
exportConfig: ExportConfig;
theme: ThemeManifest;
themeTokens: DocxThemeTokenSet;
fonts: DocxFontSource[];
metadata: MarkdownDocumentMetadata;
semanticDocument: SemanticDocumentModel;
media: PreparedDocxMedia;
@@ -225,8 +231,10 @@ export class PandocDocxConverter {
): Promise<PandocDocxConversionResult> {
let preparedMedia;
let structurePlan;
let preparedFonts;
try {
preparedMedia = preparePandocMedia(input.media);
preparedFonts = await prepareDocxFonts(input.fonts);
structurePlan = createPandocStructurePlan(
input.semanticDocument,
input.themeTokens,
@@ -367,7 +375,10 @@ export class PandocDocxConverter {
structurePlan,
input.themeTokens
);
docx = finalized.content;
docx = embedFontsInGeneratedDocx(
finalized.content,
preparedFonts
).content;
if (docx.byteLength > this.maximumOutputBytes) {
throw new Error("DOCX 结构收口后的输出大小无效");
}
+1 -1
View File
@@ -132,7 +132,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
};
const eastAsiaFontPattern =
/(?:yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|||仿||线)/iu;
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|||仿||线)/iu;
const genericFontNames = new Set([
"serif",
"sans-serif",
+159 -1
View File
@@ -10,6 +10,10 @@ import {
validateWordprocessingElementOrder,
type XmlElement
} from "./ooxml.js";
import {
readSfntFontMetadata,
xorDocxFont
} from "./font-embedding.js";
import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
@@ -25,6 +29,10 @@ const HEADER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
const FOOTER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
const FONT_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
const OBFUSCATED_FONT_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.obfuscatedFont";
const requiredStyleIds = [
"Normal",
@@ -46,6 +54,7 @@ export interface DynamicReferenceValidation {
relationshipCount: number;
headerCount: number;
footerCount: number;
embeddedFontCount: number;
}
function relationshipSourcePart(relationshipsPart: string) {
@@ -318,6 +327,150 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
}
}
function validateEmbeddedFonts(
entries: ReadonlyMap<string, Uint8Array>,
relationshipParts: ReadonlyMap<
string,
ReadonlyMap<string, Relationship>
>
) {
const partName = "word/fontTable.xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== WORD_NAMESPACE ||
root.localName !== "fonts"
) {
throw new Error("word/fontTable.xml 的根元素无效");
}
const relationships =
relationshipParts.get("word/_rels/fontTable.xml.rels") ??
new Map<string, Relationship>();
const referencedParts = new Set<string>();
const usedRelationships = new Set<string>();
const faceKeys = new Set<string>();
let embeddedFontCount = 0;
for (const font of directChildren(root, WORD_NAMESPACE, "font")) {
const family =
font.getAttributeNS(WORD_NAMESPACE, "name") ?? "";
for (const role of [
"embedRegular",
"embedBold",
"embedItalic",
"embedBoldItalic"
]) {
for (const embedded of directChildren(
font,
WORD_NAMESPACE,
role
)) {
const relationshipId =
embedded.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"id"
) ?? "";
const fontKey =
embedded.getAttributeNS(WORD_NAMESPACE, "fontKey") ??
"";
if (
!relationshipId ||
!/^\{[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\}$/iu.test(
fontKey
)
) {
throw new Error(
`word/fontTable.xml 的 ${family} ${role} 嵌入声明无效`
);
}
const faceKey = `${family.toLocaleLowerCase(
"en-US"
)}\0${role}`;
if (faceKeys.has(faceKey)) {
throw new Error(
`word/fontTable.xml 重复声明字体字形面:${family} ${role}`
);
}
faceKeys.add(faceKey);
const relationship = relationships.get(relationshipId);
if (
!relationship ||
relationship.type !== FONT_RELATIONSHIP_TYPE ||
!relationship.targetPart ||
!/^word\/fonts\/[^/]+\.odttf$/u.test(
relationship.targetPart
)
) {
throw new Error(
`word/fontTable.xml 引用了无效字体关系:${relationshipId}`
);
}
const obfuscated = entries.get(relationship.targetPart);
if (!obfuscated) {
throw new Error(
`DOCX 缺少嵌入字体部件:${relationship.targetPart}`
);
}
const metadata = readSfntFontMetadata(
xorDocxFont(obfuscated, fontKey)
);
if (metadata.family !== family) {
throw new Error(
`嵌入字体家族名不匹配:${family} != ${metadata.family}`
);
}
usedRelationships.add(relationshipId);
referencedParts.add(relationship.targetPart);
embeddedFontCount += 1;
}
}
}
for (const relationship of relationships.values()) {
if (
relationship.type === FONT_RELATIONSHIP_TYPE &&
!usedRelationships.has(relationship.id)
) {
throw new Error(
`fontTable.xml.rels 包含未使用的字体关系:${relationship.id}`
);
}
}
for (const entry of entries.keys()) {
if (
/^word\/fonts\//u.test(entry) &&
!referencedParts.has(entry)
) {
throw new Error(`DOCX 包含未引用字体部件:${entry}`);
}
}
if (embeddedFontCount > 0) {
const contentTypes = parseXmlPart(
entries.get("[Content_Types].xml")!,
"[Content_Types].xml"
);
const validContentType = directChildren(
contentTypes.documentElement!,
CONTENT_TYPES_NAMESPACE,
"Default"
).some(
(entry) =>
(entry.getAttribute("Extension") ?? "").toLowerCase() ===
"odttf" &&
entry.getAttribute("ContentType") ===
OBFUSCATED_FONT_CONTENT_TYPE
);
if (!validContentType) {
throw new Error(
"嵌入字体缺少正确的 ODTTF Content Type"
);
}
}
return embeddedFontCount;
}
function validateDocx(
content: Uint8Array,
generatedOutput: boolean
@@ -400,13 +553,18 @@ function validateDocx(
);
validateContentTypes(reference.entries, documentRelationships);
validateStyles(reference.entries);
const embeddedFontCount = validateEmbeddedFonts(
reference.entries,
relationshipParts
);
return {
partCount: reference.entries.size,
xmlPartCount,
relationshipCount,
headerCount,
footerCount
footerCount,
embeddedFontCount
};
}
@@ -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: "测试人",