feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+161 -2
View File
@@ -10,6 +10,7 @@ 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>
@@ -42,6 +43,21 @@ export interface SfntFontMetadata {
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;
@@ -64,6 +80,7 @@ export interface ObfuscatedDocxFont {
interface SfntTable {
offset: number;
length: number;
recordOffset: number;
}
function readTag(content: Uint8Array, offset: number) {
@@ -160,7 +177,7 @@ function readSfntTables(content: Uint8Array) {
if (tables.has(tag)) {
throw new Error(`SFNT 字体包含重复表:${tag}`);
}
tables.set(tag, { offset, length });
tables.set(tag, { offset, length, recordOffset });
}
return {
@@ -170,6 +187,80 @@ function readSfntTables(content: Uint8Array) {
};
}
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 长度无效");
@@ -334,12 +425,16 @@ export function readSfntFontMetadata(
const { signature, tables, view } = readSfntTables(content);
const os2 = tables.get("OS/2");
const name = tables.get("name");
if (!os2 || os2.length < 10) {
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]);
@@ -354,6 +449,52 @@ export function readSfntFontMetadata(
);
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,
@@ -365,6 +506,18 @@ export function readSfntFontMetadata(
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,
@@ -423,7 +576,13 @@ export async function prepareDocxFont(
});
}
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");