feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readGeneratedDocxPackage,
|
||||
writeGeneratedDocxPackage
|
||||
} from "./reference-package.js";
|
||||
import { createWordFontMetadata } from "./word-font-metadata.js";
|
||||
|
||||
const FONT_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
|
||||
@@ -318,17 +319,49 @@ function updateFontTable(
|
||||
font
|
||||
])
|
||||
);
|
||||
const metadataFamilies = new Set<string>();
|
||||
for (const face of faces) {
|
||||
const family = face.font.metadata.family;
|
||||
let font = byName.get(normalizeFontName(family));
|
||||
const normalizedFamily = normalizeFontName(family);
|
||||
let font = byName.get(normalizedFamily);
|
||||
if (!font) {
|
||||
font = appendElement(root, WORD_NAMESPACE, "w:font", {
|
||||
"w:name": family
|
||||
});
|
||||
appendElement(font, WORD_NAMESPACE, "w:family", {
|
||||
"w:val": "auto"
|
||||
byName.set(normalizedFamily, font);
|
||||
}
|
||||
if (!metadataFamilies.has(normalizedFamily)) {
|
||||
const metadata = createWordFontMetadata(face.font.metadata);
|
||||
removeDirectChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"panose1",
|
||||
"charset",
|
||||
"family",
|
||||
"pitch",
|
||||
"sig"
|
||||
);
|
||||
appendElement(font, WORD_NAMESPACE, "w:panose1", {
|
||||
"w:val": metadata.panose1
|
||||
});
|
||||
byName.set(normalizeFontName(family), font);
|
||||
appendElement(font, WORD_NAMESPACE, "w:charset", {
|
||||
"w:val": metadata.charset
|
||||
});
|
||||
appendElement(font, WORD_NAMESPACE, "w:family", {
|
||||
"w:val": metadata.family
|
||||
});
|
||||
appendElement(font, WORD_NAMESPACE, "w:pitch", {
|
||||
"w:val": metadata.pitch
|
||||
});
|
||||
appendElement(font, WORD_NAMESPACE, "w:sig", {
|
||||
"w:usb0": metadata.signature.usb0,
|
||||
"w:usb1": metadata.signature.usb1,
|
||||
"w:usb2": metadata.signature.usb2,
|
||||
"w:usb3": metadata.signature.usb3,
|
||||
"w:csb0": metadata.signature.csb0,
|
||||
"w:csb1": metadata.signature.csb1
|
||||
});
|
||||
metadataFamilies.add(normalizedFamily);
|
||||
}
|
||||
appendElement(font, WORD_NAMESPACE, `w:${face.role}`, {
|
||||
"r:id": face.relationshipId,
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from "./style-presets.js";
|
||||
export * from "./styles-transform.js";
|
||||
export * from "./token-style-map.js";
|
||||
export * from "./validator.js";
|
||||
export * from "./word-font-metadata.js";
|
||||
|
||||
@@ -108,6 +108,7 @@ function setParagraphSpacing(
|
||||
options: {
|
||||
beforePt: number;
|
||||
afterPt: number;
|
||||
fontSizePt: number;
|
||||
lineSpacing: number;
|
||||
firstLineIndentChars?: number;
|
||||
}
|
||||
@@ -116,8 +117,10 @@ function setParagraphSpacing(
|
||||
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
|
||||
"w:before": pointsToTwips(options.beforePt),
|
||||
"w:after": pointsToTwips(options.afterPt),
|
||||
"w:line": String(Math.round(options.lineSpacing * 240)),
|
||||
"w:lineRule": "auto"
|
||||
"w:line": pointsToTwips(
|
||||
options.fontSizePt * options.lineSpacing
|
||||
),
|
||||
"w:lineRule": "exact"
|
||||
});
|
||||
if (options.firstLineIndentChars !== undefined) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:ind", {
|
||||
@@ -208,6 +211,38 @@ function fallbackFontsForSlot(
|
||||
return fallback.body.fonts;
|
||||
}
|
||||
|
||||
function fallbackFontSizeForSlot(
|
||||
slot: DocxStyleSlotName,
|
||||
fallback: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const headingMatch = /^heading-([1-6])$/u.exec(slot);
|
||||
if (headingMatch) {
|
||||
return fallback.headings.sizesPt[
|
||||
Number.parseInt(headingMatch[1]!, 10) - 1
|
||||
]!;
|
||||
}
|
||||
if (slot === "document-title") {
|
||||
return fallback.headings.sizesPt[0] + 4;
|
||||
}
|
||||
if (slot.endsWith("-title")) {
|
||||
return fallback.headings.sizesPt[0];
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
return fallback.code.sizePt;
|
||||
}
|
||||
if (
|
||||
slot === "table" ||
|
||||
slot === "table-header" ||
|
||||
slot === "table-cell"
|
||||
) {
|
||||
return fallback.table.sizePt;
|
||||
}
|
||||
if (slot === "caption") {
|
||||
return fallback.caption.sizePt;
|
||||
}
|
||||
return fallback.body.sizePt;
|
||||
}
|
||||
|
||||
function applyTokenRunStyle(
|
||||
parent: XmlElement,
|
||||
token: DocxSlotStyleToken,
|
||||
@@ -306,7 +341,8 @@ function applyTokenBorders(
|
||||
|
||||
function applyTokenParagraphStyle(
|
||||
parent: XmlElement,
|
||||
token: DocxSlotStyleToken
|
||||
token: DocxSlotStyleToken,
|
||||
fallbackFontSizePt: number
|
||||
) {
|
||||
if (
|
||||
token.spacingBeforePt !== undefined ||
|
||||
@@ -336,9 +372,12 @@ function applyTokenParagraphStyle(
|
||||
setWordAttribute(
|
||||
spacing,
|
||||
"line",
|
||||
String(Math.round(token.lineSpacing * 240))
|
||||
pointsToTwips(
|
||||
(token.fontSizePt ?? fallbackFontSizePt) *
|
||||
token.lineSpacing
|
||||
)
|
||||
);
|
||||
setWordAttribute(spacing, "lineRule", "auto");
|
||||
setWordAttribute(spacing, "lineRule", "exact");
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -440,6 +479,7 @@ function applyBodyStyle(
|
||||
setParagraphSpacing(defaultParagraph, {
|
||||
beforePt: style.body.spacingBeforePt,
|
||||
afterPt: style.body.spacingAfterPt,
|
||||
fontSizePt: style.body.sizePt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: style.body.firstLineIndentChars
|
||||
});
|
||||
@@ -466,6 +506,7 @@ function applyBodyStyle(
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: style.body.spacingBeforePt,
|
||||
afterPt: style.body.spacingAfterPt,
|
||||
fontSizePt: style.body.sizePt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: style.body.firstLineIndentChars
|
||||
});
|
||||
@@ -492,6 +533,7 @@ function applyBodyStyle(
|
||||
{
|
||||
beforePt: 0,
|
||||
afterPt: 0,
|
||||
fontSizePt: style.body.sizePt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
}
|
||||
@@ -525,6 +567,7 @@ function applyHeadings(
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: style.headings.spacingBeforePt,
|
||||
afterPt: style.headings.spacingAfterPt,
|
||||
fontSizePt: sizePt,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) {
|
||||
@@ -569,6 +612,7 @@ function applyHeadings(
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: 0,
|
||||
afterPt: style.headings.spacingAfterPt,
|
||||
fontSizePt: sizePt,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
|
||||
@@ -622,6 +666,7 @@ function applyCodeAndQuote(
|
||||
setParagraphSpacing(codePPr, {
|
||||
beforePt: 3,
|
||||
afterPt: 3,
|
||||
fontSizePt: style.code.sizePt,
|
||||
lineSpacing: style.code.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
});
|
||||
@@ -668,6 +713,7 @@ function applyCodeAndQuote(
|
||||
setParagraphSpacing(quotePPr, {
|
||||
beforePt: 6,
|
||||
afterPt: 6,
|
||||
fontSizePt: style.body.sizePt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
});
|
||||
@@ -813,6 +859,7 @@ function applyTableAndCaption(
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: 3,
|
||||
afterPt: 6,
|
||||
fontSizePt: style.caption.sizePt,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
|
||||
@@ -1002,7 +1049,8 @@ function applyTokenStyles(
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
),
|
||||
documentToken
|
||||
documentToken,
|
||||
documentToken.fontSizePt ?? fallback.body.sizePt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1025,7 +1073,8 @@ function applyTokenStyles(
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
),
|
||||
entry.style
|
||||
entry.style,
|
||||
fallbackFontSizeForSlot(slot, fallback)
|
||||
);
|
||||
}
|
||||
applyTokenRunStyle(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
readSfntFontMetadata,
|
||||
xorDocxFont
|
||||
} from "./font-embedding.js";
|
||||
import { createWordFontMetadata } from "./word-font-metadata.js";
|
||||
import {
|
||||
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
|
||||
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
|
||||
@@ -355,6 +356,7 @@ function validateEmbeddedFonts(
|
||||
for (const font of directChildren(root, WORD_NAMESPACE, "font")) {
|
||||
const family =
|
||||
font.getAttributeNS(WORD_NAMESPACE, "name") ?? "";
|
||||
let validatedMetadata = false;
|
||||
for (const role of [
|
||||
"embedRegular",
|
||||
"embedBold",
|
||||
@@ -420,6 +422,62 @@ function validateEmbeddedFonts(
|
||||
`嵌入字体家族名不匹配:${family} != ${metadata.family}`
|
||||
);
|
||||
}
|
||||
if (!validatedMetadata) {
|
||||
const expected = createWordFontMetadata(metadata);
|
||||
const panose1 = directChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"panose1"
|
||||
)[0];
|
||||
const charset = directChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"charset"
|
||||
)[0];
|
||||
const wordFamily = directChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"family"
|
||||
)[0];
|
||||
const pitch = directChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"pitch"
|
||||
)[0];
|
||||
const signature = directChildren(
|
||||
font,
|
||||
WORD_NAMESPACE,
|
||||
"sig"
|
||||
)[0];
|
||||
const wordValue = (
|
||||
element: XmlElement | undefined,
|
||||
name: string
|
||||
) =>
|
||||
element?.getAttributeNS(WORD_NAMESPACE, name) ?? "";
|
||||
if (
|
||||
wordValue(panose1, "val") !== expected.panose1 ||
|
||||
wordValue(charset, "val") !== expected.charset ||
|
||||
wordValue(wordFamily, "val") !== expected.family ||
|
||||
wordValue(pitch, "val") !== expected.pitch ||
|
||||
wordValue(signature, "usb0") !==
|
||||
expected.signature.usb0 ||
|
||||
wordValue(signature, "usb1") !==
|
||||
expected.signature.usb1 ||
|
||||
wordValue(signature, "usb2") !==
|
||||
expected.signature.usb2 ||
|
||||
wordValue(signature, "usb3") !==
|
||||
expected.signature.usb3 ||
|
||||
wordValue(signature, "csb0") !==
|
||||
expected.signature.csb0 ||
|
||||
wordValue(signature, "csb1") !==
|
||||
expected.signature.csb1
|
||||
) {
|
||||
throw new Error(
|
||||
`word/fontTable.xml 的 ${family} 字体元数据与嵌入字形不匹配`
|
||||
);
|
||||
}
|
||||
validatedMetadata = true;
|
||||
}
|
||||
usedRelationships.add(relationshipId);
|
||||
referencedParts.add(relationship.targetPart);
|
||||
embeddedFontCount += 1;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { SfntFontMetadata } from "./font-embedding.js";
|
||||
|
||||
export type WordFontFamily =
|
||||
| "auto"
|
||||
| "decorative"
|
||||
| "modern"
|
||||
| "roman"
|
||||
| "script"
|
||||
| "swiss";
|
||||
|
||||
export interface WordFontMetadata {
|
||||
panose1: string;
|
||||
charset: string;
|
||||
family: WordFontFamily;
|
||||
pitch: "fixed" | "variable";
|
||||
signature: {
|
||||
usb0: string;
|
||||
usb1: string;
|
||||
usb2: string;
|
||||
usb3: string;
|
||||
csb0: string;
|
||||
csb1: string;
|
||||
};
|
||||
}
|
||||
|
||||
const codePageCharsets = [
|
||||
{ bit: 18, charset: "86" },
|
||||
{ bit: 20, charset: "88" },
|
||||
{ bit: 17, charset: "80" },
|
||||
{ bit: 19, charset: "81" },
|
||||
{ bit: 21, charset: "82" },
|
||||
{ bit: 16, charset: "DE" },
|
||||
{ bit: 6, charset: "B2" },
|
||||
{ bit: 5, charset: "B1" },
|
||||
{ bit: 7, charset: "BA" },
|
||||
{ bit: 2, charset: "CC" },
|
||||
{ bit: 3, charset: "A1" },
|
||||
{ bit: 4, charset: "A2" },
|
||||
{ bit: 31, charset: "02" }
|
||||
] as const;
|
||||
|
||||
function uint32Hex(value: number) {
|
||||
return value.toString(16).toUpperCase().padStart(8, "0");
|
||||
}
|
||||
|
||||
function byteHex(value: number) {
|
||||
return value.toString(16).toUpperCase().padStart(2, "0");
|
||||
}
|
||||
|
||||
function hasCodePageBit(
|
||||
ranges: SfntFontMetadata["codePageRanges"],
|
||||
bit: number
|
||||
) {
|
||||
const range = ranges[Math.floor(bit / 32)] ?? 0;
|
||||
return ((range >>> (bit % 32)) & 1) === 1;
|
||||
}
|
||||
|
||||
function resolveCharset(metadata: SfntFontMetadata) {
|
||||
return (
|
||||
codePageCharsets.find(({ bit }) =>
|
||||
hasCodePageBit(metadata.codePageRanges, bit)
|
||||
)?.charset ?? "00"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFamily(metadata: SfntFontMetadata): WordFontFamily {
|
||||
if (metadata.fixedPitch) {
|
||||
return "modern";
|
||||
}
|
||||
const [familyType, serifStyle] = metadata.panose;
|
||||
if (familyType === 3) {
|
||||
return "script";
|
||||
}
|
||||
if (familyType === 4) {
|
||||
return "decorative";
|
||||
}
|
||||
if (familyType !== 2) {
|
||||
return "auto";
|
||||
}
|
||||
if (serifStyle >= 11 && serifStyle <= 13) {
|
||||
return "swiss";
|
||||
}
|
||||
if (
|
||||
(serifStyle >= 2 && serifStyle <= 10) ||
|
||||
serifStyle === 14 ||
|
||||
serifStyle === 15
|
||||
) {
|
||||
return "roman";
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function createWordFontMetadata(
|
||||
metadata: SfntFontMetadata
|
||||
): WordFontMetadata {
|
||||
const [usb0, usb1, usb2, usb3] = metadata.unicodeRanges;
|
||||
const [csb0, csb1] = metadata.codePageRanges;
|
||||
return {
|
||||
panose1: metadata.panose.map(byteHex).join(""),
|
||||
charset: resolveCharset(metadata),
|
||||
family: resolveFamily(metadata),
|
||||
pitch: metadata.fixedPitch ? "fixed" : "variable",
|
||||
signature: {
|
||||
usb0: uint32Hex(usb0),
|
||||
usb1: uint32Hex(usb1),
|
||||
usb2: uint32Hex(usb2),
|
||||
usb3: uint32Hex(usb3),
|
||||
csb0: uint32Hex(csb0),
|
||||
csb1: uint32Hex(csb1)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,10 @@ describe("DOCX 字体嵌入", () => {
|
||||
family: "FandolFang",
|
||||
subfamily: "Regular",
|
||||
postscriptName: "FandolFang-Regular",
|
||||
panose: [0, 0, 5, 0, 0, 0, 0, 0, 0, 0],
|
||||
unicodeRanges: [0x80000001, 0x00002000, 0x00000002, 0],
|
||||
codePageRanges: [0x00060007, 0],
|
||||
fixedPitch: false,
|
||||
fsType: 0x0008,
|
||||
permission: "editable",
|
||||
noSubsetting: false,
|
||||
@@ -123,6 +127,9 @@ describe("DOCX 字体嵌入", () => {
|
||||
"FandolHei",
|
||||
"FandolKai"
|
||||
]);
|
||||
expect(
|
||||
prepared.map((font) => font.metadata.weightClass)
|
||||
).toEqual([400, 700, 400, 400, 400]);
|
||||
expect(
|
||||
prepared.every((font) => font.metadata.signature === "OTTO")
|
||||
).toBe(true);
|
||||
|
||||
@@ -97,6 +97,15 @@ describe("DOCX 字体包写入", () => {
|
||||
output.entries.get("word/fontTable.xml")
|
||||
);
|
||||
expect(fontTable).toContain('w:name="FandolFang"');
|
||||
expect(fontTable).toContain(
|
||||
'<w:panose1 w:val="00000500000000000000"/>'
|
||||
);
|
||||
expect(fontTable).toContain('<w:charset w:val="86"/>');
|
||||
expect(fontTable).toContain('<w:family w:val="auto"/>');
|
||||
expect(fontTable).toContain('<w:pitch w:val="variable"/>');
|
||||
expect(fontTable).toContain(
|
||||
'<w:sig w:usb0="80000001" w:usb1="00002000" w:usb2="00000002" w:usb3="00000000" w:csb0="00060007" w:csb1="00000000"/>'
|
||||
);
|
||||
expect(fontTable).toContain("<w:embedRegular");
|
||||
expect(fontTable).toContain(
|
||||
`xmlns:r="${relationshipNamespace}"`
|
||||
@@ -124,6 +133,22 @@ describe("DOCX 字体包写入", () => {
|
||||
expect(validateGeneratedDocx(result.content)).toMatchObject({
|
||||
embeddedFontCount: 1
|
||||
});
|
||||
|
||||
const invalidEntries = new Map(output.entries);
|
||||
invalidEntries.set(
|
||||
"word/fontTable.xml",
|
||||
encoder.encode(
|
||||
fontTable.replace(
|
||||
'<w:charset w:val="86"/>',
|
||||
'<w:charset w:val="00"/>'
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(() =>
|
||||
validateGeneratedDocx(
|
||||
writeGeneratedDocxPackage(invalidEntries)
|
||||
)
|
||||
).toThrow("字体元数据与嵌入字形不匹配");
|
||||
});
|
||||
|
||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||
|
||||
@@ -279,6 +279,15 @@ describe("动态 reference.docx", () => {
|
||||
alignment: "center",
|
||||
keepWithNext: true
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "official-edition",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
lineSpacing: 1.5
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -300,6 +309,13 @@ describe("动态 reference.docx", () => {
|
||||
expect(stylesXml).toContain('w:val="AA0000"');
|
||||
expect(stylesXml).toContain('<w:jc w:val="both"/>');
|
||||
expect(stylesXml).not.toContain('w:val="justify"');
|
||||
expect(stylesXml).toContain(
|
||||
'w:line="504" w:lineRule="exact"'
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:lineRule="auto"');
|
||||
expect(stylesXml).not.toContain("<w:tblW");
|
||||
expect(stylesXml).toContain('w:color="445566"');
|
||||
expect(fontTableXml).toContain(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SfntFontMetadata } from "../src/font-embedding.js";
|
||||
import { createWordFontMetadata } from "../src/word-font-metadata.js";
|
||||
|
||||
function metadata(
|
||||
overrides: Partial<SfntFontMetadata> = {}
|
||||
): SfntFontMetadata {
|
||||
return {
|
||||
family: "Test Font",
|
||||
subfamily: "Regular",
|
||||
weightClass: 400,
|
||||
panose: [2, 2, 5, 3, 5, 4, 5, 2, 3, 4],
|
||||
unicodeRanges: [
|
||||
0x20000083,
|
||||
0x2adf3c10,
|
||||
0x00000016,
|
||||
0
|
||||
],
|
||||
codePageRanges: [0x60060107, 0],
|
||||
fixedPitch: false,
|
||||
fsType: 0,
|
||||
permission: "installable",
|
||||
noSubsetting: false,
|
||||
signature: "\u0000\u0001\u0000\u0000",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("Word 字体元数据映射", () => {
|
||||
it("将 CJK Serif 的 OS/2 范围映射为简体中文 Word 字体签名", () => {
|
||||
expect(createWordFontMetadata(metadata())).toEqual({
|
||||
panose1: "02020503050405020304",
|
||||
charset: "86",
|
||||
family: "roman",
|
||||
pitch: "variable",
|
||||
signature: {
|
||||
usb0: "20000083",
|
||||
usb1: "2ADF3C10",
|
||||
usb2: "00000016",
|
||||
usb3: "00000000",
|
||||
csb0: "60060107",
|
||||
csb1: "00000000"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("根据 Panose 与固定字距通用推导 Word 字体族", () => {
|
||||
expect(
|
||||
createWordFontMetadata(
|
||||
metadata({
|
||||
panose: [2, 11, 5, 3, 5, 4, 5, 2, 3, 4]
|
||||
})
|
||||
).family
|
||||
).toBe("swiss");
|
||||
expect(
|
||||
createWordFontMetadata(
|
||||
metadata({
|
||||
panose: [3, 0, 5, 3, 5, 4, 5, 2, 3, 4]
|
||||
})
|
||||
).family
|
||||
).toBe("script");
|
||||
expect(
|
||||
createWordFontMetadata(
|
||||
metadata({
|
||||
fixedPitch: true
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
family: "modern",
|
||||
pitch: "fixed"
|
||||
});
|
||||
});
|
||||
|
||||
it("在没有 CJK code-page 位时选择对应 charset 或默认 ANSI", () => {
|
||||
expect(
|
||||
createWordFontMetadata(
|
||||
metadata({ codePageRanges: [1 << 2, 0] })
|
||||
).charset
|
||||
).toBe("CC");
|
||||
expect(
|
||||
createWordFontMetadata(
|
||||
metadata({ codePageRanges: [0, 0] })
|
||||
).charset
|
||||
).toBe("00");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user