feat: 实现 DOCX 可编辑字体嵌入
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 结构收口后的输出大小无效");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "测试人",
|
||||
|
||||
Reference in New Issue
Block a user