680 lines
16 KiB
TypeScript
680 lines
16 KiB
TypeScript
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 SFNT_CHECKSUM_MAGIC = 0xb1b0afba;
|
|
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;
|
|
panose: readonly [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
number
|
|
];
|
|
unicodeRanges: readonly [number, number, number, number];
|
|
codePageRanges: readonly [number, number];
|
|
fixedPitch: boolean;
|
|
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;
|
|
recordOffset: 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, recordOffset });
|
|
}
|
|
|
|
return {
|
|
signature: signature as SfntFontMetadata["signature"],
|
|
tables,
|
|
view
|
|
};
|
|
}
|
|
|
|
function calculateSfntChecksum(
|
|
content: Uint8Array,
|
|
offset = 0,
|
|
length = content.byteLength
|
|
) {
|
|
assertRange(content, offset, length, "校验和");
|
|
let checksum = 0;
|
|
const paddedLength = Math.ceil(length / 4) * 4;
|
|
for (
|
|
let relativeOffset = 0;
|
|
relativeOffset < paddedLength;
|
|
relativeOffset += 4
|
|
) {
|
|
let word = 0;
|
|
for (let byteIndex = 0; byteIndex < 4; byteIndex += 1) {
|
|
const index = relativeOffset + byteIndex;
|
|
word =
|
|
(word << 8) |
|
|
(index < length ? (content[offset + index] ?? 0) : 0);
|
|
}
|
|
checksum = (checksum + (word >>> 0)) >>> 0;
|
|
}
|
|
return checksum;
|
|
}
|
|
|
|
function normalizeSfntWeightClass(
|
|
content: Uint8Array,
|
|
weightClass: number
|
|
) {
|
|
if (
|
|
!Number.isInteger(weightClass) ||
|
|
weightClass < 1 ||
|
|
weightClass > 1000
|
|
) {
|
|
throw new Error("字体声明权重必须位于 1 到 1000 之间");
|
|
}
|
|
const { tables, view } = readSfntTables(content);
|
|
const os2 = tables.get("OS/2");
|
|
const head = tables.get("head");
|
|
if (!os2 || os2.length < 6) {
|
|
throw new Error("SFNT 字体缺少可写入权重的 OS/2 表");
|
|
}
|
|
if (!head || head.length < 12) {
|
|
throw new Error("SFNT 字体缺少完整 head 表");
|
|
}
|
|
if (view.getUint16(os2.offset + 4, false) === weightClass) {
|
|
return;
|
|
}
|
|
|
|
view.setUint16(os2.offset + 4, weightClass, false);
|
|
view.setUint32(
|
|
os2.recordOffset + 4,
|
|
calculateSfntChecksum(content, os2.offset, os2.length),
|
|
false
|
|
);
|
|
|
|
const adjustmentOffset = head.offset + 8;
|
|
view.setUint32(adjustmentOffset, 0, false);
|
|
view.setUint32(
|
|
head.recordOffset + 4,
|
|
calculateSfntChecksum(content, head.offset, head.length),
|
|
false
|
|
);
|
|
const checksum = calculateSfntChecksum(content);
|
|
view.setUint32(
|
|
adjustmentOffset,
|
|
(SFNT_CHECKSUM_MAGIC - checksum) >>> 0,
|
|
false
|
|
);
|
|
if (calculateSfntChecksum(content) !== SFNT_CHECKSUM_MAGIC) {
|
|
throw new Error("SFNT 字体元数据规范化后校验和无效");
|
|
}
|
|
}
|
|
|
|
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");
|
|
const post = tables.get("post");
|
|
if (!os2 || os2.length < 68) {
|
|
throw new Error("SFNT 字体缺少完整 OS/2 表");
|
|
}
|
|
if (!name) {
|
|
throw new Error("SFNT 字体缺少 name 表");
|
|
}
|
|
if (post && post.length < 16) {
|
|
throw new Error("SFNT 字体 post 表不完整");
|
|
}
|
|
|
|
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]);
|
|
const panose = Array.from(
|
|
content.subarray(os2.offset + 32, os2.offset + 42)
|
|
) as unknown as SfntFontMetadata["panose"];
|
|
const unicodeRanges = [
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 42,
|
|
"OS/2 ulUnicodeRange1"
|
|
),
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 46,
|
|
"OS/2 ulUnicodeRange2"
|
|
),
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 50,
|
|
"OS/2 ulUnicodeRange3"
|
|
),
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 54,
|
|
"OS/2 ulUnicodeRange4"
|
|
)
|
|
] as const;
|
|
const codePageRanges =
|
|
os2.length >= 86
|
|
? ([
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 78,
|
|
"OS/2 ulCodePageRange1"
|
|
),
|
|
readUint32(
|
|
view,
|
|
content,
|
|
os2.offset + 82,
|
|
"OS/2 ulCodePageRange2"
|
|
)
|
|
] as const)
|
|
: ([0, 0] as const);
|
|
|
|
return {
|
|
family,
|
|
subfamily,
|
|
...(postscriptName ? { postscriptName } : {}),
|
|
weightClass: readUint16(
|
|
view,
|
|
content,
|
|
os2.offset + 4,
|
|
"OS/2 usWeightClass"
|
|
),
|
|
panose,
|
|
unicodeRanges,
|
|
codePageRanges,
|
|
fixedPitch:
|
|
post === undefined
|
|
? false
|
|
: readUint32(
|
|
view,
|
|
content,
|
|
post.offset + 12,
|
|
"post isFixedPitch"
|
|
) !== 0,
|
|
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();
|
|
normalizeSfntWeightClass(sfnt, source.weight);
|
|
const metadata = readSfntFontMetadata(sfnt);
|
|
if (metadata.weightClass !== source.weight) {
|
|
throw new Error(
|
|
`字体内部权重 ${metadata.weightClass} 与声明权重 ${source.weight} 不一致`
|
|
);
|
|
}
|
|
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`
|
|
};
|
|
}
|