diff --git a/apps/server/scripts/verify-docx-r4-matrix.mjs b/apps/server/scripts/verify-docx-r4-matrix.mjs index 8d3f175..0a45832 100644 --- a/apps/server/scripts/verify-docx-r4-matrix.mjs +++ b/apps/server/scripts/verify-docx-r4-matrix.mjs @@ -172,6 +172,11 @@ function inspectDocx(content) { const fontNames = [ ...fontTableXml.matchAll(/ match[1]); + const embeddedFontNames = [ + ...fontTableXml.matchAll(//gu) + ].flatMap((match) => /)[\s\S]*?<\/w:p>/gu) ].map((match) => match[0]); @@ -205,6 +210,9 @@ function inspectDocx(content) { fontNames: [...new Set(fontNames)].sort((first, second) => first.localeCompare(second, "en") ), + embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) => + first.localeCompare(second, "en") + ), optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"), hasAltChunk: /)/u.test(documentXml), internalMarkerCount: @@ -448,12 +456,6 @@ try { const enhancedThemes = results.filter( (result) => result.docx.inspection.optionalFontPackApplied ); -if (themes.some((theme) => theme.id === "gov-red-standard")) { - assert( - enhancedThemes.some((result) => result.id === "gov-red-standard"), - "有效字体包未增强 gov-red-standard" - ); -} for (const result of results) { assert( result.pdf.diagnostics.pageCount > 0, diff --git a/packages/document-visual-diff/src/snapshot.ts b/packages/document-visual-diff/src/snapshot.ts index 7d97240..f81e779 100644 --- a/packages/document-visual-diff/src/snapshot.ts +++ b/packages/document-visual-diff/src/snapshot.ts @@ -7,6 +7,7 @@ import { aggregatePdfTextLines, buildPageContentText, normalizePdfText } from ". import type { CreatePdfSnapshotOptions, PdfDocumentSnapshot, + PdfFontSnapshot, PdfPageSnapshot, PdfSnapshotLimits, PdfTextItemSnapshot, @@ -25,6 +26,13 @@ interface PdfJsTextStyle { fontFamily?: string; } +interface PdfJsResolvedFont { + name?: string; + fallbackName?: string; + isType3Font?: boolean; + missingFile?: boolean; +} + export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = { maxPdfBytes: 100 * 1024 * 1024, maxPages: 500, @@ -60,6 +68,7 @@ function createTextItemSnapshot( item: PdfJsTextItem, pageHeightPt: number, styles: Readonly>, + fonts: ReadonlyMap, ): PdfTextItemSnapshot | undefined { const normalizedText = normalizePdfText(item.str); if (!normalizedText) { @@ -77,7 +86,8 @@ function createTextItemSnapshot( 0, ); const baselineY = pageHeightPt - baselineFromBottom; - const fontFamily = styles[item.fontName]?.fontFamily; + const font = fonts.get(item.fontName); + const fontFamily = font?.name ?? styles[item.fontName]?.fontFamily; return { text: item.str, normalizedText, @@ -90,10 +100,45 @@ function createTextItemSnapshot( baselineY, fontName: item.fontName, ...(fontFamily ? { fontFamily } : {}), + ...(font?.fallbackFamily + ? { fallbackFontFamily: font.fallbackFamily } + : {}), hasEol: item.hasEOL, }; } +function normalizePdfFontName(value: string) { + return value.replace(/^[A-Z]{6}\+/u, ""); +} + +function resolvePageFonts( + page: Awaited["promise"]>["getPage"]>>, + fontNames: readonly string[], +) { + const fonts = new Map(); + for (const internalName of fontNames) { + try { + const resolved = page.commonObjs.get(internalName) as PdfJsResolvedFont; + const name = resolved.name?.trim(); + if (!name) { + continue; + } + fonts.set(internalName, { + internalName, + name: normalizePdfFontName(name), + ...(resolved.fallbackName + ? { fallbackFamily: resolved.fallbackName } + : {}), + isType3Font: resolved.isType3Font === true, + missingFile: resolved.missingFile === true, + }); + } catch { + // PDF.js 可能不给未实际绘制的字体建立公共对象;保留文本但不伪造字体。 + } + } + return fonts; +} + function resolveLimits( input: Partial | undefined, ): PdfSnapshotLimits { @@ -145,6 +190,17 @@ export async function createPdfDocumentSnapshot( disableNormalization: false, includeMarkedContent: false, }); + await page.getOperatorList(); + const pageFonts = resolvePageFonts( + page, + [ + ...new Set( + textContent.items.flatMap((item) => + isTextItem(item) ? [item.fontName] : [], + ), + ), + ], + ); const styles = textContent.styles as Readonly< Record >; @@ -156,6 +212,7 @@ export async function createPdfDocumentSnapshot( item, pointViewport.height, styles, + pageFonts, ); return snapshot ? [snapshot] : []; }); @@ -168,6 +225,9 @@ export async function createPdfDocumentSnapshot( items, lines, contentText: buildPageContentText(lines), + fonts: [...pageFonts.values()].sort((left, right) => + left.name.localeCompare(right.name, "en"), + ), }; const pageNumberLine = lines.find((line) => line.role === "page-number"); if (pageNumberLine) { @@ -218,12 +278,20 @@ export async function createPdfDocumentSnapshot( .map((page) => page.contentText) .filter(Boolean) .join("\n\f\n"); + const fonts = [ + ...new Map( + pages + .flatMap((page) => page.fonts) + .map((font) => [font.name.toLocaleLowerCase("en-US"), font]), + ).values(), + ].sort((left, right) => left.name.localeCompare(right.name, "en")); return { schemaVersion: 1, source: options.source, sha256: sha256(pdfBytes), pageCount: pages.length, contentText, + fonts, pages, }; } finally { diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts index 59daf35..56bc45e 100644 --- a/packages/document-visual-diff/src/types.ts +++ b/packages/document-visual-diff/src/types.ts @@ -20,9 +20,18 @@ export interface PdfTextItemSnapshot { baselineY: number; fontName?: string; fontFamily?: string; + fallbackFontFamily?: string; hasEol: boolean; } +export interface PdfFontSnapshot { + internalName: string; + name: string; + fallbackFamily?: string; + isType3Font: boolean; + missingFile: boolean; +} + export type PdfTextLineRole = "content" | "page-number"; export interface PdfTextLineSnapshot { @@ -132,6 +141,7 @@ export interface PdfPageSnapshot { items: PdfTextItemSnapshot[]; lines: PdfTextLineSnapshot[]; contentText: string; + fonts: PdfFontSnapshot[]; pageNumberText?: string; raster?: PdfPageRaster; } @@ -142,6 +152,7 @@ export interface PdfDocumentSnapshot { sha256: string; pageCount: number; contentText: string; + fonts: PdfFontSnapshot[]; pages: PdfPageSnapshot[]; } diff --git a/packages/document-visual-diff/tests/snapshot.test.ts b/packages/document-visual-diff/tests/snapshot.test.ts index 18ded23..e6704d2 100644 --- a/packages/document-visual-diff/tests/snapshot.test.ts +++ b/packages/document-visual-diff/tests/snapshot.test.ts @@ -19,6 +19,18 @@ describe("PDF.js 页面快照", () => { expect(first.pages[0]?.widthPt).toBeCloseTo(595, 3); expect(first.pages[0]?.heightPt).toBeCloseTo(842, 3); expect(first.contentText).toContain("Visual diff fixture"); + expect(first.fonts).toEqual([ + expect.objectContaining({ + name: "Helvetica", + fallbackFamily: "sans-serif", + isType3Font: false, + missingFile: true, + }), + ]); + expect(first.pages[0]?.items[0]).toMatchObject({ + fontFamily: "Helvetica", + fallbackFontFamily: "sans-serif", + }); expect(first.pages[0]?.raster).toMatchObject({ widthPx: 1190, heightPx: 1684, diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs index 56c79a8..76a3397 100644 --- a/scripts/docx-layout-visual-matrix-cases.mjs +++ b/scripts/docx-layout-visual-matrix-cases.mjs @@ -101,6 +101,14 @@ export function readBundledThemeMatrixDefinitions(themesDirectory) { compatibleProfiles: Array.isArray(manifest.compatibleProfiles) ? [...manifest.compatibleProfiles] : [], + docxFontFaces: Array.isArray(manifest.docxFonts?.faces) + ? manifest.docxFonts.faces.map((face) => ({ + family: face.family, + aliases: Array.isArray(face.aliases) ? [...face.aliases] : [], + weight: face.weight ?? 400, + style: face.style ?? "normal" + })) + : [], themeDefaultMargins: manifest.pageDefaults?.margins ? structuredClone(manifest.pageDefaults.margins) : undefined @@ -109,6 +117,68 @@ export function readBundledThemeMatrixDefinitions(themesDirectory) { .sort((left, right) => left.id.localeCompare(right.id, "en")); } +function normalizeFontName(value) { + return value + .replace(/^[A-Z]{6}\+/u, "") + .replace(/(?:[-, ](?:regular|bold|italic|bolditalic|roman|medium|light|black))+$/iu, "") + .replace(/[\s_-]+/gu, "") + .toLocaleLowerCase("en-US"); +} + +function isOfficeEmbeddedAlias(value, engine) { + return engine === "word" + ? /^___WRD_EMBED_SUB_/iu.test(value) + : engine === "wps" + ? /^WPSEMBED\d+$/iu.test(value) + : false; +} + +export function getDocxFontGateFailures({ + theme, + inspection, + renderedFonts, + engine +}) { + assert(theme && typeof theme.id === "string", "字体门禁缺少主题定义"); + assert(inspection && typeof inspection === "object", "字体门禁缺少 DOCX 检查结果"); + assert(Array.isArray(renderedFonts), "字体门禁缺少实际渲染字体"); + assert(engine === "word" || engine === "wps", "字体门禁引擎必须为 word 或 wps"); + const failures = []; + const declaredFaces = Array.isArray(theme.docxFontFaces) + ? theme.docxFontFaces + : []; + const embeddedNames = Array.isArray(inspection.embeddedFontNames) + ? inspection.embeddedFontNames + : []; + const embeddedPartCount = Number(inspection.embeddedFontPartCount ?? 0); + if (declaredFaces.length === 0) { + failures.push(`主题 ${theme.id} 未声明任何 DOCX 字体`); + } + if (embeddedPartCount === 0) { + failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`); + } + if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) { + failures.push( + `主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件` + ); + } + if (embeddedPartCount > 0) { + const expected = new Set(embeddedNames.map(normalizeFontName)); + const actuallyUsed = renderedFonts.some((font) => { + const name = typeof font === "string" ? font : font?.name; + return typeof name === "string" && ( + isOfficeEmbeddedAlias(name, engine) || expected.has(normalizeFontName(name)) + ); + }); + if (!actuallyUsed) { + failures.push( + `${engine === "word" ? "Microsoft Word" : "WPS Writer"} 输出未实际使用 DOCX 嵌入字体` + ); + } + } + return failures; +} + function createPaperConfig(orientation, marginScenario) { return { format: "A4", diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs index d61b192..5d2405c 100644 --- a/scripts/docx-layout-visual-matrix-cases.test.mjs +++ b/scripts/docx-layout-visual-matrix-cases.test.mjs @@ -7,6 +7,7 @@ import { createDocxLayoutVisualMatrixCases, DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS, DOCX_LAYOUT_MATRIX_ORIENTATIONS, + getDocxFontGateFailures, readBundledThemeMatrixDefinitions, summarizeDocxLayoutVisualMatrix } from "./docx-layout-visual-matrix-cases.mjs"; @@ -34,6 +35,38 @@ test("生成 14×2×5 的完整 DOCX 布局视觉矩阵", () => { assert.deepEqual(summary.orientations, ["portrait", "landscape"]); }); +test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用", () => { + const missing = getDocxFontGateFailures({ + theme: { id: "missing", docxFontFaces: [] }, + inspection: { embeddedFontPartCount: 0, embeddedFontNames: [] }, + renderedFonts: [], + engine: "word" + }); + assert.equal(missing.length, 2); + + const fallback = getDocxFontGateFailures({ + theme: { + id: "declared", + docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }] + }, + inspection: { embeddedFontPartCount: 1, embeddedFontNames: ["MdTP Serif SC"] }, + renderedFonts: [{ name: "SimSun" }], + engine: "word" + }); + assert.deepEqual(fallback, ["Microsoft Word 输出未实际使用 DOCX 嵌入字体"]); + + const embedded = getDocxFontGateFailures({ + theme: { + id: "declared", + docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }] + }, + inspection: { embeddedFontPartCount: 1, embeddedFontNames: ["MdTP Serif SC"] }, + renderedFonts: [{ name: "___WRD_EMBED_SUB_65" }], + engine: "word" + }); + assert.deepEqual(embedded, []); +}); + test("每套主题都覆盖两个方向和五组边距", () => { const { themes, cases } = matrix(); for (const theme of themes) { diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs index f0de9ba..c502d09 100644 --- a/scripts/verify-docx-layout-visual-matrix.mjs +++ b/scripts/verify-docx-layout-visual-matrix.mjs @@ -20,6 +20,7 @@ import { } from "../packages/preview-engine/dist/index.js"; import { createDocxLayoutVisualMatrixCases, + getDocxFontGateFailures, readBundledThemeMatrixDefinitions, summarizeDocxLayoutVisualMatrix } from "./docx-layout-visual-matrix-cases.mjs"; @@ -568,14 +569,26 @@ for (const caseDefinition of selectedCases) { writeReportArtifacts(rasterDirectory, "wps", wpsReport); writeReportArtifacts(rasterDirectory, "office", officeReport); - const currentFailures = [ + const currentFailures = [...new Set([ ...pageGeometryFailures(chromium, caseDefinition, "Chromium"), ...pageGeometryFailures(word, caseDefinition, "Microsoft Word"), ...pageGeometryFailures(wps, caseDefinition, "WPS Writer"), + ...getDocxFontGateFailures({ + theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), + inspection: result.docx.inspection, + renderedFonts: word.fonts, + engine: "word" + }), + ...getDocxFontGateFailures({ + theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId), + inspection: result.docx.inspection, + renderedFonts: wps.fonts, + engine: "wps" + }), ...reportGateFailures(wordReport, "Chromium/Word"), ...reportGateFailures(wpsReport, "Chromium/WPS"), ...reportGateFailures(officeReport, "Word/WPS") - ]; + ])]; gateFailures.push( ...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`) ); @@ -595,6 +608,14 @@ for (const caseDefinition of selectedCases) { word: word.pageCount, wps: wps.pageCount }, + fonts: { + declared: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId)?.docxFontFaces ?? [], + embeddedNames: result.docx.inspection.embeddedFontNames, + embeddedPartCount: result.docx.inspection.embeddedFontPartCount, + chromium: chromium.fonts, + word: word.fonts, + wps: wps.fonts + }, editableParagraphCount: editable.paragraphs.length, reports: { word: reportSummary(wordReport),