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
+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
};
}