From e5da699ea311f40d960831a38130e3f54a29a179 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Fri, 31 Jul 2026 20:13:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20DOCX=20=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E9=97=A8=E7=A6=81=E4=B8=8E=E5=AD=97=E4=BD=93=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 29 +- package-lock.json | 16 +- package.json | 4 +- packages/document-visual-diff/package.json | 28 ++ .../src/adapter-runner.ts | 82 +++++ packages/document-visual-diff/src/adapters.ts | 90 +++++ packages/document-visual-diff/src/compare.ts | 225 +++++++++++ packages/document-visual-diff/src/index.ts | 9 + .../src/office-adapter.ts | 348 ++++++++++++++++++ packages/document-visual-diff/src/raster.ts | 255 +++++++++++++ packages/document-visual-diff/src/report.ts | 296 +++++++++++++++ packages/document-visual-diff/src/snapshot.ts | 218 +++++++++++ packages/document-visual-diff/src/text.ts | 158 ++++++++ packages/document-visual-diff/src/types.ts | 189 ++++++++++ .../tests/adapters.test.ts | 128 +++++++ .../tests/compare.test.ts | 79 ++++ .../document-visual-diff/tests/pdf-fixture.ts | 30 ++ .../document-visual-diff/tests/raster.test.ts | 53 +++ .../document-visual-diff/tests/report.test.ts | 100 +++++ .../tests/snapshot.test.ts | 40 ++ .../document-visual-diff/tests/text.test.ts | 64 ++++ packages/document-visual-diff/tsconfig.json | 19 + packages/docx-engine/src/font-embedding.ts | 163 +++++++- .../docx-engine/src/font-package-transform.ts | 41 ++- packages/docx-engine/src/index.ts | 1 + packages/docx-engine/src/styles-transform.ts | 63 +++- packages/docx-engine/src/validator.ts | 58 +++ .../docx-engine/src/word-font-metadata.ts | 112 ++++++ .../docx-engine/tests/font-embedding.test.ts | 7 + .../tests/font-package-transform.test.ts | 25 ++ .../tests/reference-builder.test.ts | 16 + .../tests/word-font-metadata.test.ts | 86 +++++ 32 files changed, 3015 insertions(+), 17 deletions(-) create mode 100644 packages/document-visual-diff/package.json create mode 100644 packages/document-visual-diff/src/adapter-runner.ts create mode 100644 packages/document-visual-diff/src/adapters.ts create mode 100644 packages/document-visual-diff/src/compare.ts create mode 100644 packages/document-visual-diff/src/index.ts create mode 100644 packages/document-visual-diff/src/office-adapter.ts create mode 100644 packages/document-visual-diff/src/raster.ts create mode 100644 packages/document-visual-diff/src/report.ts create mode 100644 packages/document-visual-diff/src/snapshot.ts create mode 100644 packages/document-visual-diff/src/text.ts create mode 100644 packages/document-visual-diff/src/types.ts create mode 100644 packages/document-visual-diff/tests/adapters.test.ts create mode 100644 packages/document-visual-diff/tests/compare.test.ts create mode 100644 packages/document-visual-diff/tests/pdf-fixture.ts create mode 100644 packages/document-visual-diff/tests/raster.test.ts create mode 100644 packages/document-visual-diff/tests/report.test.ts create mode 100644 packages/document-visual-diff/tests/snapshot.test.ts create mode 100644 packages/document-visual-diff/tests/text.test.ts create mode 100644 packages/document-visual-diff/tsconfig.json create mode 100644 packages/docx-engine/src/word-font-metadata.ts create mode 100644 packages/docx-engine/tests/word-font-metadata.test.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 12afbbd..26531ca 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -251,6 +251,27 @@ Word/WPS 均为 2 页;红头主题在 Word 为 2 页、WPS 为 3 页,版记 写回器产生的 OOXML 顺序问题放宽自身严格门禁。完整记录见 [DOCX 字体嵌入与代表主题验收](V0.6.0_DOCX_FONT_EMBEDDING.md)。 +阶段 12D-R3 已完成独立 `packages/document-visual-diff` 视觉差异引擎和 +字体兼容收口。引擎通过可替换适配器调用 Word、WPS 或既有 PDF,统一 +提取页面、文字与栅格快照,并以页数、纸张、正文相似度、平均像素误差、 +变化像素率、墨迹 IoU 和边缘 IoU 生成 JSON/HTML 报告;所有 Office +自动化均使用独立临时输出并在结束后清理。DOCX 样式层将固定 CSS 行高 +写为 Word 精确行距,修复 WPS 中红头版记被挤到第三页的问题;字体解析 +同时规范可变字体字重,并从 SFNT `OS/2`、`head`、`post` 表通用提取 +Panose、字符集、字体族、间距和 Unicode/代码页签名,写入 +`fontTable.xml`。映射与严格校验均不按主题或字体名称分支,校验器会 +拒绝字体表声明与实际嵌入字体不一致的文档。 + +使用原生静态 TrueType 中文字体资产的两字形面验证中,Word 与 WPS 均 +正确使用嵌入字体显示红头版头、标题和页码,文档保持 2 页、可选择和 +编辑;Word 导出 PDF 相对基线两页的正文相似度均为 1,第一页 MAE +为 1.986、墨迹/边缘 IoU 为 0.812/0.805,第二页 MAE 为 1.176、 +墨迹/边缘 IoU 为 0.786/0.832,未发现裁切、重叠、缺字或异常页眉页脚。 +静态字体单面约 14.85 MiB,不进入主安装包;后续如立项字体包,将采用 +独立可选安装与运行时发现机制。WPS 默认保存会移除嵌入字体、Word 会 +保留使用字形子集,这是客户端写回行为,不影响导出文件的首次打开和 +编辑门禁。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1132,6 +1153,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX 样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、 请求级临时转换和最终结构校验。 +- `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与 + 栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和 + 发布验收,不进入应用运行时。 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron @@ -1188,7 +1212,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 表格内容区全宽、分页控制、OOXML 严格性和 14 主题真实 Word/WPS 无修复打开门禁; - 阶段 12D-R2:已完成可分发字体嵌入和三套代表主题真实 Word/WPS - 编辑互存;下一步 R3 建立 PDF 与 DOCX 自动视觉差异引擎,再由 R4 完成 + 编辑互存; +- 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体 + 字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS + 原生视觉与可编辑性验收;下一步先对齐可选字体包架构,再由 R4 完成 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 diff --git a/package-lock.json b/package-lock.json index cae4a4d..28836eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2187,6 +2187,10 @@ "resolved": "apps/desktop", "link": true }, + "node_modules/@md-to-pdf/document-visual-diff": { + "resolved": "packages/document-visual-diff", + "link": true + }, "node_modules/@md-to-pdf/docx-engine": { "resolved": "packages/docx-engine", "link": true @@ -2355,8 +2359,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "devOptional": true, "license": "MIT", - "optional": true, "workspaces": [ "e2e/*" ], @@ -9978,6 +9982,16 @@ "vitest": "^4.1.10" } }, + "packages/document-visual-diff": { + "name": "@md-to-pdf/document-visual-diff", + "version": "0.1.0", + "devDependencies": { + "@napi-rs/canvas": "1.0.2", + "@types/node": "^24.10.1", + "pdfjs-dist": "6.1.200", + "vitest": "^4.1.10" + } + }, "packages/docx-engine": { "name": "@md-to-pdf/docx-engine", "version": "0.1.0", diff --git a/package.json b/package.json index 493227f..1384352 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", "verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts", "verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server", - "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", - "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" + "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", + "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { "node": ">=22" diff --git a/packages/document-visual-diff/package.json b/packages/document-visual-diff/package.json new file mode 100644 index 0000000..45e3efd --- /dev/null +++ b/packages/document-visual-diff/package.json @@ -0,0 +1,28 @@ +{ + "name": "@md-to-pdf/document-visual-diff", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + }, + "devDependencies": { + "@napi-rs/canvas": "1.0.2", + "@types/node": "^24.10.1", + "pdfjs-dist": "6.1.200", + "vitest": "^4.1.10" + } +} diff --git a/packages/document-visual-diff/src/adapter-runner.ts b/packages/document-visual-diff/src/adapter-runner.ts new file mode 100644 index 0000000..f2de5b9 --- /dev/null +++ b/packages/document-visual-diff/src/adapter-runner.ts @@ -0,0 +1,82 @@ +import { + type PdfAdapterGeneration, + type PdfArtifactAdapter, +} from "./adapters.js"; +import { createPdfVisualDiffReport } from "./report.js"; +import { createPdfDocumentSnapshot } from "./snapshot.js"; +import type { + CreatePdfSnapshotOptions, + CreatePdfVisualDiffOptions, + PdfDocumentSnapshot, + PdfVisualDiffReport, +} from "./types.js"; + +export interface PreparedPdfAdapterRun { + id: string; + generate(): Promise; +} + +export interface PdfAdapterVisualDiffResult { + baseline: PdfAdapterGeneration; + baselineSnapshot: PdfDocumentSnapshot; + candidates: Array<{ + generation: PdfAdapterGeneration; + snapshot: PdfDocumentSnapshot; + report: PdfVisualDiffReport; + }>; +} + +export interface RunPdfAdapterVisualDiffOptions { + baseline: PreparedPdfAdapterRun; + candidates: PreparedPdfAdapterRun[]; + snapshot?: Omit; + report?: CreatePdfVisualDiffOptions; +} + +export function preparePdfAdapterRun( + adapter: PdfArtifactAdapter, + input: TInput, +): PreparedPdfAdapterRun { + return { + id: adapter.id, + generate: () => adapter.generate(input), + }; +} + +export async function runPdfAdapterVisualDiff( + options: RunPdfAdapterVisualDiffOptions, +): Promise { + if (options.candidates.length === 0) { + throw new Error("视觉差异编排至少需要一个候选适配器"); + } + const baseline = await options.baseline.generate(); + const baselineSnapshot = await createPdfDocumentSnapshot( + baseline.pdf, + { + ...options.snapshot, + source: baseline.source, + }, + ); + const candidates: PdfAdapterVisualDiffResult["candidates"] = []; + for (const candidateRun of options.candidates) { + const generation = await candidateRun.generate(); + const snapshot = await createPdfDocumentSnapshot( + generation.pdf, + { + ...options.snapshot, + source: generation.source, + }, + ); + const report = await createPdfVisualDiffReport( + baselineSnapshot, + snapshot, + options.report, + ); + candidates.push({ generation, snapshot, report }); + } + return { + baseline, + baselineSnapshot, + candidates, + }; +} diff --git a/packages/document-visual-diff/src/adapters.ts b/packages/document-visual-diff/src/adapters.ts new file mode 100644 index 0000000..e8f1776 --- /dev/null +++ b/packages/document-visual-diff/src/adapters.ts @@ -0,0 +1,90 @@ +import { performance } from "node:perf_hooks"; + +import type { PdfSnapshotSource } from "./types.js"; + +export interface PdfAdapterCapability { + available: boolean; + adapterId: string; + source: PdfSnapshotSource; + detail?: string; +} + +export interface PdfAdapterGeneration { + source: PdfSnapshotSource; + pdf: Uint8Array; + pageCount?: number; + elapsedMs: number; + diagnostics: string[]; +} + +export interface PdfArtifactAdapter { + readonly id: string; + readonly source: PdfSnapshotSource; + probe(): Promise; + generate(input: TInput): Promise; +} + +export type ChromiumPdfProducerResult = + | Uint8Array + | { + pdf: Uint8Array; + pageCount?: number; + diagnostics?: string[]; + }; + +export interface ChromiumPdfAdapterOptions { + label?: string; + fileName?: string; + generate: (input: TInput) => Promise; +} + +export function assertPdfBytes( + pdf: Uint8Array, + label = "PDF", +): void { + if (!(pdf instanceof Uint8Array) || pdf.byteLength < 8) { + throw new Error(`${label} 内容无效或过短`); + } + const signature = new TextDecoder("ascii").decode(pdf.subarray(0, 5)); + if (signature !== "%PDF-") { + throw new Error(`${label} 缺少有效的 PDF 文件签名`); + } +} + +export function createChromiumPdfAdapter( + options: ChromiumPdfAdapterOptions, +): PdfArtifactAdapter { + const source: PdfSnapshotSource = { + kind: "chromium", + label: options.label ?? "Chromium", + ...(options.fileName ? { fileName: options.fileName } : {}), + }; + return { + id: "chromium", + source, + async probe() { + return { + available: true, + adapterId: "chromium", + source, + detail: "由调用方提供现有 Chromium PDF 生成链", + }; + }, + async generate(input) { + const startedAt = performance.now(); + const produced = await options.generate(input); + const result = + produced instanceof Uint8Array ? { pdf: produced } : produced; + assertPdfBytes(result.pdf, source.label); + return { + source, + pdf: result.pdf, + ...(result.pageCount === undefined + ? {} + : { pageCount: result.pageCount }), + elapsedMs: performance.now() - startedAt, + diagnostics: result.diagnostics ?? [], + }; + }, + }; +} diff --git a/packages/document-visual-diff/src/compare.ts b/packages/document-visual-diff/src/compare.ts new file mode 100644 index 0000000..0c2112a --- /dev/null +++ b/packages/document-visual-diff/src/compare.ts @@ -0,0 +1,225 @@ +import { normalizePdfContentText } from "./text.js"; +import type { + PdfBasicComparison, + PdfDocumentSnapshot, + PdfPagePair, + VisualDiffIssue, + VisualDiffThresholds, +} from "./types.js"; + +export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = { + pageSizeDeltaPt: 0.5, + contentSimilarity: 0.999, + strictPageCount: true, + pixelDifferenceThreshold: 8, + maxMeanAbsoluteError: 8, + maxChangedPixelRatio: 0.05, + minInkIou: 0.75, + minEdgeIou: 0.6, +}; + +function cappedLevenshteinDistance( + left: string, + right: string, + cap: number, +): number { + if (left === right) { + return 0; + } + if (Math.abs(left.length - right.length) > cap) { + return cap + 1; + } + let previous = new Int32Array(right.length + 1); + let current = new Int32Array(right.length + 1); + for (let column = 0; column <= right.length; column += 1) { + previous[column] = column; + } + for (let row = 1; row <= left.length; row += 1) { + current.fill(cap + 1); + current[0] = row; + const start = Math.max(1, row - cap); + const end = Math.min(right.length, row + cap); + let rowMinimum = cap + 1; + for (let column = start; column <= end; column += 1) { + const substitutionCost = + left.charCodeAt(row - 1) === right.charCodeAt(column - 1) ? 0 : 1; + const value = Math.min( + (previous[column] ?? cap + 1) + 1, + (current[column - 1] ?? cap + 1) + 1, + (previous[column - 1] ?? cap + 1) + substitutionCost, + ); + current[column] = value; + rowMinimum = Math.min(rowMinimum, value); + } + if (rowMinimum > cap) { + return cap + 1; + } + [previous, current] = [current, previous]; + } + return Math.min(previous[right.length] ?? cap + 1, cap + 1); +} + +export function calculateContentSimilarity( + leftInput: string, + rightInput: string, + requiredSimilarity = DEFAULT_VISUAL_DIFF_THRESHOLDS.contentSimilarity, +): number { + const left = normalizePdfContentText(leftInput); + const right = normalizePdfContentText(rightInput); + const maximumLength = Math.max(left.length, right.length); + if (maximumLength === 0) { + return 1; + } + const cap = Math.max( + 1, + Math.ceil(maximumLength * (1 - requiredSimilarity)), + ); + const distance = cappedLevenshteinDistance(left, right, cap); + return Math.max(0, 1 - distance / maximumLength); +} + +function createPagePairs( + baseline: PdfDocumentSnapshot, + candidate: PdfDocumentSnapshot, +): PdfPagePair[] { + const pairCount = Math.max(baseline.pageCount, candidate.pageCount); + return Array.from({ length: pairCount }, (_, index) => { + const baselinePageNumber = + index < baseline.pageCount ? index + 1 : undefined; + const candidatePageNumber = + index < candidate.pageCount ? index + 1 : undefined; + if (baselinePageNumber === undefined) { + if (candidatePageNumber === undefined) { + throw new Error("页面配对状态无效"); + } + return { + candidatePageNumber, + status: "candidate-only" as const, + }; + } + if (candidatePageNumber === undefined) { + return { + baselinePageNumber, + status: "baseline-only" as const, + }; + } + return { + baselinePageNumber, + candidatePageNumber, + status: "paired" as const, + }; + }); +} + +export function comparePdfSnapshotsBasic( + baseline: PdfDocumentSnapshot, + candidate: PdfDocumentSnapshot, + thresholdOverrides: Partial = {}, +): PdfBasicComparison { + const thresholds = { + ...DEFAULT_VISUAL_DIFF_THRESHOLDS, + ...thresholdOverrides, + }; + const issues: VisualDiffIssue[] = []; + const pagePairs = createPagePairs(baseline, candidate); + + if (baseline.pageCount !== candidate.pageCount) { + issues.push({ + code: "PAGE_COUNT_MISMATCH", + severity: thresholds.strictPageCount ? "failure" : "warning", + message: `页数不一致:基线 ${baseline.pageCount} 页,候选 ${candidate.pageCount} 页`, + details: { + baselinePageCount: baseline.pageCount, + candidatePageCount: candidate.pageCount, + }, + }); + } + + for (const pair of pagePairs) { + if (pair.status === "baseline-only") { + issues.push({ + code: "UNPAIRED_BASELINE_PAGE", + severity: "failure", + message: `基线第 ${pair.baselinePageNumber} 页没有候选配对页`, + baselinePageNumber: pair.baselinePageNumber, + }); + continue; + } + if (pair.status === "candidate-only") { + issues.push({ + code: "UNPAIRED_CANDIDATE_PAGE", + severity: "failure", + message: `候选第 ${pair.candidatePageNumber} 页为溢出页`, + candidatePageNumber: pair.candidatePageNumber, + }); + continue; + } + const baselinePage = baseline.pages[pair.baselinePageNumber - 1]; + const candidatePage = candidate.pages[pair.candidatePageNumber - 1]; + if (!baselinePage || !candidatePage) { + continue; + } + const widthDelta = Math.abs( + baselinePage.widthPt - candidatePage.widthPt, + ); + const heightDelta = Math.abs( + baselinePage.heightPt - candidatePage.heightPt, + ); + if ( + widthDelta > thresholds.pageSizeDeltaPt || + heightDelta > thresholds.pageSizeDeltaPt + ) { + issues.push({ + code: "PAGE_SIZE_MISMATCH", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页纸张尺寸偏差超限`, + baselinePageNumber: pair.baselinePageNumber, + candidatePageNumber: pair.candidatePageNumber, + details: { widthDeltaPt: widthDelta, heightDeltaPt: heightDelta }, + }); + } + const baselineLandscape = + baselinePage.widthPt > baselinePage.heightPt; + const candidateLandscape = + candidatePage.widthPt > candidatePage.heightPt; + if (baselineLandscape !== candidateLandscape) { + issues.push({ + code: "PAGE_ORIENTATION_MISMATCH", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页方向不一致`, + baselinePageNumber: pair.baselinePageNumber, + candidatePageNumber: pair.candidatePageNumber, + }); + } + } + + const contentSimilarity = calculateContentSimilarity( + baseline.contentText, + candidate.contentText, + thresholds.contentSimilarity, + ); + if (contentSimilarity < thresholds.contentSimilarity) { + issues.push({ + code: "CONTENT_MISMATCH", + severity: "failure", + message: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`, + details: { contentSimilarity }, + }); + } + + const status = issues.some((issue) => issue.severity === "failure") + ? "failed" + : issues.length > 0 + ? "warning" + : "passed"; + return { + schemaVersion: 1, + status, + baseline: baseline.source, + candidate: candidate.source, + thresholds, + contentSimilarity, + pagePairs, + issues, + }; +} diff --git a/packages/document-visual-diff/src/index.ts b/packages/document-visual-diff/src/index.ts new file mode 100644 index 0000000..a2767ac --- /dev/null +++ b/packages/document-visual-diff/src/index.ts @@ -0,0 +1,9 @@ +export * from "./adapter-runner.js"; +export * from "./adapters.js"; +export * from "./compare.js"; +export * from "./office-adapter.js"; +export * from "./raster.js"; +export * from "./report.js"; +export * from "./snapshot.js"; +export * from "./text.js"; +export * from "./types.js"; diff --git a/packages/document-visual-diff/src/office-adapter.ts b/packages/document-visual-diff/src/office-adapter.ts new file mode 100644 index 0000000..88ec0dd --- /dev/null +++ b/packages/document-visual-diff/src/office-adapter.ts @@ -0,0 +1,348 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, extname, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; +import { promisify } from "node:util"; + +import { + assertPdfBytes, + type PdfAdapterCapability, + type PdfAdapterGeneration, + type PdfArtifactAdapter, +} from "./adapters.js"; +import type { PdfSnapshotSource } from "./types.js"; + +const execFileAsync = promisify(execFile); +const MAX_OFFICE_INPUT_BYTES = 250 * 1024 * 1024; +const MAX_OFFICE_OUTPUT_BYTES = 500 * 1024 * 1024; + +export type OfficeClientKind = "word" | "wps"; + +export interface OfficePdfAdapterInput { + docxPath: string; +} + +export interface OfficeExportMetadata { + pageCount?: number; + bytes: number; +} + +export interface OfficeAutomationBackend { + probe(progId: string, timeoutMs: number): Promise; + exportPdf(options: { + client: OfficeClientKind; + progId: string; + inputPath: string; + outputPath: string; + timeoutMs: number; + }): Promise; +} + +export interface OfficePdfAdapterOptions { + label?: string; + timeoutMs?: number; + backend?: OfficeAutomationBackend; +} + +interface OfficeDescriptor { + id: string; + client: OfficeClientKind; + progId: string; + defaultLabel: string; +} + +const POWERSHELL_PROBE_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$progId = $env:MD_TO_PDF_OFFICE_PROGID +$type = [Type]::GetTypeFromProgID($progId, $false) +if ($null -eq $type) { exit 2 } +[pscustomobject]@{ available = $true; progId = $progId } | + ConvertTo-Json -Compress +`; + +const POWERSHELL_EXPORT_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$client = $env:MD_TO_PDF_OFFICE_CLIENT +$progId = $env:MD_TO_PDF_OFFICE_PROGID +$resolvedInput = (Resolve-Path -LiteralPath $env:MD_TO_PDF_OFFICE_INPUT).Path +$resolvedOutput = [System.IO.Path]::GetFullPath($env:MD_TO_PDF_OFFICE_OUTPUT) +if ([System.IO.File]::Exists($resolvedOutput)) { + throw "Office PDF 输出已存在:$resolvedOutput" +} +$application = $null +$document = $null +try { + $application = New-Object -ComObject $progId + $application.Visible = $false + try { $application.DisplayAlerts = 0 } catch {} + if ($client -eq 'word') { + $application.AutomationSecurity = 3 + $application.Options.SaveNormalPrompt = $false + $document = $application.Documents.OpenNoRepairDialog( + $resolvedInput, $false, $true, $false, '', '', $false, + '', '', 0, 0, $false, $false, 0, $true + ) + } else { + try { $application.AutomationSecurity = 3 } catch {} + $document = $application.Documents.Open( + $resolvedInput, $false, $true, $false + ) + } + $pageCount = $null + try { $pageCount = $document.ComputeStatistics(2) } catch {} + $document.ExportAsFixedFormat($resolvedOutput, 17) + [pscustomobject]@{ + pages = $pageCount + bytes = (Get-Item -LiteralPath $resolvedOutput).Length + } | ConvertTo-Json -Compress +} finally { + if ($null -ne $document) { + try { $document.Close($false) } finally { + [System.Runtime.InteropServices.Marshal]::ReleaseComObject( + $document + ) | Out-Null + } + } + if ($null -ne $application) { + try { $application.Quit() } finally { + [System.Runtime.InteropServices.Marshal]::ReleaseComObject( + $application + ) | Out-Null + } + } + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} +`; + +function encodePowerShell(script: string): string { + return Buffer.from(script, "utf16le").toString("base64"); +} + +function parseJsonLine( + stdout: string, +): Record { + const line = stdout + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean) + .at(-1); + if (!line) { + throw new Error("Office 自动化没有返回结果"); + } + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Office 自动化返回格式无效"); + } + return parsed as Record; +} + +export class PowerShellOfficeAutomationBackend + implements OfficeAutomationBackend +{ + async probe(progId: string, timeoutMs: number): Promise { + if (process.platform !== "win32") { + return false; + } + try { + await execFileAsync( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(POWERSHELL_PROBE_SCRIPT), + ], + { + timeout: Math.min(timeoutMs, 15_000), + windowsHide: true, + encoding: "utf8", + env: { + ...process.env, + MD_TO_PDF_OFFICE_PROGID: progId, + }, + }, + ); + return true; + } catch { + return false; + } + } + + async exportPdf(options: { + client: OfficeClientKind; + progId: string; + inputPath: string; + outputPath: string; + timeoutMs: number; + }): Promise { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(POWERSHELL_EXPORT_SCRIPT), + ], + { + timeout: options.timeoutMs, + windowsHide: true, + encoding: "utf8", + maxBuffer: 1024 * 1024, + env: { + ...process.env, + MD_TO_PDF_OFFICE_CLIENT: options.client, + MD_TO_PDF_OFFICE_PROGID: options.progId, + MD_TO_PDF_OFFICE_INPUT: options.inputPath, + MD_TO_PDF_OFFICE_OUTPUT: options.outputPath, + }, + }, + ); + const parsed = parseJsonLine(stdout); + const bytes = parsed.bytes; + const pages = parsed.pages; + if (typeof bytes !== "number" || !Number.isSafeInteger(bytes)) { + throw new Error("Office 自动化未返回有效的 PDF 字节数"); + } + return { + bytes, + ...(typeof pages === "number" && Number.isSafeInteger(pages) + ? { pageCount: pages } + : {}), + }; + } +} + +async function validateDocxPath(inputPath: string): Promise { + if (extname(inputPath).toLowerCase() !== ".docx") { + throw new Error("Office PDF 适配器只接受 .docx 文件"); + } + const resolvedPath = await realpath(resolve(inputPath)); + const inputStat = await stat(resolvedPath); + if (!inputStat.isFile()) { + throw new Error("Office PDF 输入必须是普通文件"); + } + if ( + inputStat.size <= 0 || + inputStat.size > MAX_OFFICE_INPUT_BYTES + ) { + throw new Error( + `Office PDF 输入大小超限:${inputStat.size}`, + ); + } + return resolvedPath; +} + +function createOfficePdfAdapter( + descriptor: OfficeDescriptor, + options: OfficePdfAdapterOptions = {}, +): PdfArtifactAdapter { + const backend = + options.backend ?? new PowerShellOfficeAutomationBackend(); + const timeoutMs = options.timeoutMs ?? 180_000; + const source: PdfSnapshotSource = { + kind: descriptor.client, + label: options.label ?? descriptor.defaultLabel, + }; + return { + id: descriptor.id, + source, + async probe(): Promise { + const available = await backend.probe( + descriptor.progId, + timeoutMs, + ); + return { + available, + adapterId: descriptor.id, + source, + detail: available + ? `已注册 ${descriptor.progId}` + : `未找到 ${descriptor.progId}`, + }; + }, + async generate( + input: OfficePdfAdapterInput, + ): Promise { + const inputPath = await validateDocxPath(input.docxPath); + const temporaryDirectory = await mkdtemp( + join(tmpdir(), `md-to-pdf-${descriptor.client}-`), + ); + const outputPath = join( + temporaryDirectory, + `${basename(inputPath, extname(inputPath))}.pdf`, + ); + const startedAt = performance.now(); + try { + const metadata = await backend.exportPdf({ + client: descriptor.client, + progId: descriptor.progId, + inputPath, + outputPath, + timeoutMs, + }); + const outputStat = await stat(outputPath); + if ( + outputStat.size <= 0 || + outputStat.size > MAX_OFFICE_OUTPUT_BYTES || + outputStat.size !== metadata.bytes + ) { + throw new Error( + `Office PDF 输出大小无效:${outputStat.size}`, + ); + } + const pdf = Uint8Array.from(await readFile(outputPath)); + assertPdfBytes(pdf, source.label); + return { + source, + pdf, + ...(metadata.pageCount === undefined + ? {} + : { pageCount: metadata.pageCount }), + elapsedMs: performance.now() - startedAt, + diagnostics: [], + }; + } finally { + await rm(temporaryDirectory, { + recursive: true, + force: true, + }); + } + }, + }; +} + +export function createWordPdfAdapter( + options: OfficePdfAdapterOptions = {}, +): PdfArtifactAdapter { + return createOfficePdfAdapter( + { + id: "word", + client: "word", + progId: "Word.Application", + defaultLabel: "Microsoft Word", + }, + options, + ); +} + +export function createWpsPdfAdapter( + options: OfficePdfAdapterOptions = {}, +): PdfArtifactAdapter { + return createOfficePdfAdapter( + { + id: "wps", + client: "wps", + progId: "KWPS.Application", + defaultLabel: "WPS Writer", + }, + options, + ); +} diff --git a/packages/document-visual-diff/src/raster.ts b/packages/document-visual-diff/src/raster.ts new file mode 100644 index 0000000..18af623 --- /dev/null +++ b/packages/document-visual-diff/src/raster.ts @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; + +import { createCanvas, loadImage } from "@napi-rs/canvas"; + +import type { + PdfPageRaster, + PdfRasterDiffArtifacts, + PdfRasterDiffMetrics, + PdfRasterDiffResult, +} from "./types.js"; + +interface DecodedRaster { + width: number; + height: number; + rgba: Uint8ClampedArray; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function decodeRaster( + raster: PdfPageRaster, + width: number, + height: number, +): Promise { + const image = await loadImage(Buffer.from(raster.png)); + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0); + return { + width, + height, + rgba: context.getImageData(0, 0, width, height).data, + }; +} + +function luminance( + rgba: Uint8ClampedArray, + offset: number, +): number { + const red = rgba[offset] ?? 255; + const green = rgba[offset + 1] ?? 255; + const blue = rgba[offset + 2] ?? 255; + return (77 * red + 150 * green + 29 * blue) >> 8; +} + +function calculateBinaryIou( + left: Uint8Array, + right: Uint8Array, +): number { + let intersection = 0; + let union = 0; + for (let index = 0; index < left.length; index += 1) { + const leftValue = left[index] === 1; + const rightValue = right[index] === 1; + if (leftValue || rightValue) { + union += 1; + if (leftValue && rightValue) { + intersection += 1; + } + } + } + return union === 0 ? 1 : intersection / union; +} + +function createEdgeMask( + grayscale: Uint8Array, + width: number, + height: number, + threshold: number, +): Uint8Array { + const edges = new Uint8Array(width * height); + for (let y = 1; y < height - 1; y += 1) { + const rowOffset = y * width; + for (let x = 1; x < width - 1; x += 1) { + const index = rowOffset + x; + const horizontal = Math.abs( + (grayscale[index + 1] ?? 255) - + (grayscale[index - 1] ?? 255), + ); + const vertical = Math.abs( + (grayscale[index + width] ?? 255) - + (grayscale[index - width] ?? 255), + ); + if (horizontal + vertical >= threshold) { + edges[index] = 1; + } + } + } + return edges; +} + +function encodeRgbaPng( + rgba: Uint8ClampedArray, + width: number, + height: number, +): Uint8Array { + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d"); + const imageData = context.createImageData(width, height); + imageData.data.set(rgba); + context.putImageData(imageData, 0, 0); + return Uint8Array.from(canvas.toBuffer("image/png")); +} + +function createArtifacts( + baseline: PdfPageRaster, + candidate: PdfPageRaster, + baselineGray: Uint8Array, + candidateGray: Uint8Array, + pixelDifference: Uint8Array, + width: number, + height: number, +): PdfRasterDiffArtifacts { + const overlay = new Uint8ClampedArray(width * height * 4); + const heatmap = new Uint8ClampedArray(width * height * 4); + for (let pixelIndex = 0; pixelIndex < width * height; pixelIndex += 1) { + const outputOffset = pixelIndex * 4; + const baselineValue = baselineGray[pixelIndex] ?? 255; + const candidateValue = candidateGray[pixelIndex] ?? 255; + const difference = pixelDifference[pixelIndex] ?? 0; + + overlay[outputOffset] = baselineValue; + overlay[outputOffset + 1] = candidateValue; + overlay[outputOffset + 2] = candidateValue; + overlay[outputOffset + 3] = 255; + + if (difference === 0) { + heatmap[outputOffset] = 255; + heatmap[outputOffset + 1] = 255; + heatmap[outputOffset + 2] = 255; + } else { + const intensity = Math.min(255, difference * 4); + heatmap[outputOffset] = 255; + heatmap[outputOffset + 1] = 255 - intensity; + heatmap[outputOffset + 2] = Math.max(0, 160 - intensity); + } + heatmap[outputOffset + 3] = 255; + } + const overlayPng = encodeRgbaPng(overlay, width, height); + const heatmapPng = encodeRgbaPng(heatmap, width, height); + return { + baselinePng: baseline.png, + candidatePng: candidate.png, + overlayPng, + heatmapPng, + baselineSha256: baseline.sha256, + candidateSha256: candidate.sha256, + overlaySha256: sha256(overlayPng), + heatmapSha256: sha256(heatmapPng), + }; +} + +export async function comparePageRasters( + baseline: PdfPageRaster, + candidate: PdfPageRaster, + pixelDifferenceThreshold = 8, +): Promise { + if ( + !Number.isInteger(pixelDifferenceThreshold) || + pixelDifferenceThreshold < 0 || + pixelDifferenceThreshold > 255 + ) { + throw new Error("像素变化阈值必须是 0 到 255 之间的整数"); + } + const width = Math.max(baseline.widthPx, candidate.widthPx); + const height = Math.max(baseline.heightPx, candidate.heightPx); + const [baselineDecoded, candidateDecoded] = await Promise.all([ + decodeRaster(baseline, width, height), + decodeRaster(candidate, width, height), + ]); + const pixelCount = width * height; + const baselineGray = new Uint8Array(pixelCount); + const candidateGray = new Uint8Array(pixelCount); + const pixelDifference = new Uint8Array(pixelCount); + const baselineInk = new Uint8Array(pixelCount); + const candidateInk = new Uint8Array(pixelCount); + let absoluteError = 0; + let changedPixels = 0; + + for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { + const offset = pixelIndex * 4; + const redDifference = Math.abs( + (baselineDecoded.rgba[offset] ?? 255) - + (candidateDecoded.rgba[offset] ?? 255), + ); + const greenDifference = Math.abs( + (baselineDecoded.rgba[offset + 1] ?? 255) - + (candidateDecoded.rgba[offset + 1] ?? 255), + ); + const blueDifference = Math.abs( + (baselineDecoded.rgba[offset + 2] ?? 255) - + (candidateDecoded.rgba[offset + 2] ?? 255), + ); + absoluteError += redDifference + greenDifference + blueDifference; + const maximumDifference = Math.max( + redDifference, + greenDifference, + blueDifference, + ); + pixelDifference[pixelIndex] = maximumDifference; + if (maximumDifference > pixelDifferenceThreshold) { + changedPixels += 1; + } + const baselineValue = luminance(baselineDecoded.rgba, offset); + const candidateValue = luminance(candidateDecoded.rgba, offset); + baselineGray[pixelIndex] = baselineValue; + candidateGray[pixelIndex] = candidateValue; + if (baselineValue < 245) { + baselineInk[pixelIndex] = 1; + } + if (candidateValue < 245) { + candidateInk[pixelIndex] = 1; + } + } + + const baselineEdges = createEdgeMask( + baselineGray, + width, + height, + 40, + ); + const candidateEdges = createEdgeMask( + candidateGray, + width, + height, + 40, + ); + const metrics: PdfRasterDiffMetrics = { + comparedWidthPx: width, + comparedHeightPx: height, + dimensionsMatch: + baseline.widthPx === candidate.widthPx && + baseline.heightPx === candidate.heightPx, + meanAbsoluteError: absoluteError / (pixelCount * 3), + changedPixelRatio: changedPixels / pixelCount, + inkIou: calculateBinaryIou(baselineInk, candidateInk), + edgeIou: calculateBinaryIou(baselineEdges, candidateEdges), + }; + return { + metrics, + artifacts: createArtifacts( + baseline, + candidate, + baselineGray, + candidateGray, + pixelDifference, + width, + height, + ), + }; +} diff --git a/packages/document-visual-diff/src/report.ts b/packages/document-visual-diff/src/report.ts new file mode 100644 index 0000000..c8b8d14 --- /dev/null +++ b/packages/document-visual-diff/src/report.ts @@ -0,0 +1,296 @@ +import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js"; +import { comparePageRasters } from "./raster.js"; +import type { + CreatePdfVisualDiffOptions, + PdfDocumentSnapshot, + PdfPagePair, + PdfVisualDiffReport, + PdfVisualPageComparison, + VisualDiffIssue, + VisualDiffThresholds, +} from "./types.js"; + +function resolveStatus( + issues: readonly VisualDiffIssue[], +): "passed" | "warning" | "failed" { + if (issues.some((issue) => issue.severity === "failure")) { + return "failed"; + } + return issues.length > 0 ? "warning" : "passed"; +} + +function rasterIssues( + pair: Extract, + metrics: NonNullable, + thresholds: VisualDiffThresholds, +): VisualDiffIssue[] { + const location = { + baselinePageNumber: pair.baselinePageNumber, + candidatePageNumber: pair.candidatePageNumber, + }; + const issues: VisualDiffIssue[] = []; + if (!metrics.dimensionsMatch) { + issues.push({ + code: "RASTER_SIZE_MISMATCH", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页栅格尺寸不一致`, + ...location, + details: { + comparedWidthPx: metrics.comparedWidthPx, + comparedHeightPx: metrics.comparedHeightPx, + }, + }); + } + if (metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError) { + issues.push({ + code: "PIXEL_MAE_EXCEEDED", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页平均像素误差 ${metrics.meanAbsoluteError.toFixed(3)} 超过 ${thresholds.maxMeanAbsoluteError}`, + ...location, + details: { meanAbsoluteError: metrics.meanAbsoluteError }, + }); + } + if (metrics.changedPixelRatio > thresholds.maxChangedPixelRatio) { + issues.push({ + code: "CHANGED_PIXEL_RATIO_EXCEEDED", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页变化像素率 ${(metrics.changedPixelRatio * 100).toFixed(3)}% 超过 ${(thresholds.maxChangedPixelRatio * 100).toFixed(3)}%`, + ...location, + details: { changedPixelRatio: metrics.changedPixelRatio }, + }); + } + if (metrics.inkIou < thresholds.minInkIou) { + issues.push({ + code: "INK_IOU_BELOW_THRESHOLD", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页墨迹 IoU ${metrics.inkIou.toFixed(4)} 低于 ${thresholds.minInkIou}`, + ...location, + details: { inkIou: metrics.inkIou }, + }); + } + if (metrics.edgeIou < thresholds.minEdgeIou) { + issues.push({ + code: "EDGE_IOU_BELOW_THRESHOLD", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页边缘 IoU ${metrics.edgeIou.toFixed(4)} 低于 ${thresholds.minEdgeIou}`, + ...location, + details: { edgeIou: metrics.edgeIou }, + }); + } + return issues; +} + +async function compareVisualPage( + pair: PdfPagePair, + baseline: PdfDocumentSnapshot, + candidate: PdfDocumentSnapshot, + thresholds: VisualDiffThresholds, +): Promise { + if (pair.status !== "paired") { + const page = + pair.status === "baseline-only" + ? baseline.pages[pair.baselinePageNumber - 1] + : candidate.pages[pair.candidatePageNumber - 1]; + return { + pair, + status: "failed", + ...(page?.raster + ? { + unpairedPng: page.raster.png, + unpairedSha256: page.raster.sha256, + } + : {}), + issues: [], + }; + } + const baselinePage = baseline.pages[pair.baselinePageNumber - 1]; + const candidatePage = candidate.pages[pair.candidatePageNumber - 1]; + if (!baselinePage?.raster || !candidatePage?.raster) { + const issue: VisualDiffIssue = { + code: "RASTER_MISSING", + severity: "failure", + message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`, + baselinePageNumber: pair.baselinePageNumber, + candidatePageNumber: pair.candidatePageNumber, + }; + return { + pair, + status: "failed", + issues: [issue], + }; + } + const result = await comparePageRasters( + baselinePage.raster, + candidatePage.raster, + thresholds.pixelDifferenceThreshold, + ); + const issues = rasterIssues(pair, result.metrics, thresholds); + return { + pair, + status: resolveStatus(issues), + metrics: result.metrics, + artifacts: result.artifacts, + issues, + }; +} + +export async function createPdfVisualDiffReport( + baseline: PdfDocumentSnapshot, + candidate: PdfDocumentSnapshot, + options: CreatePdfVisualDiffOptions = {}, +): Promise { + const thresholds = { + ...DEFAULT_VISUAL_DIFF_THRESHOLDS, + ...options.thresholds, + }; + const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds); + const pages: PdfVisualPageComparison[] = []; + for (const pair of basic.pagePairs) { + pages.push( + await compareVisualPage(pair, baseline, candidate, thresholds), + ); + } + const issues = [ + ...basic.issues, + ...pages.flatMap((page) => page.issues), + ]; + return { + schemaVersion: 1, + generatedAt: options.generatedAt ?? new Date().toISOString(), + status: resolveStatus(issues), + basic, + pages, + issues, + }; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function pngDataUri(bytes: Uint8Array): string { + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +function percentage(value: number): string { + return `${(value * 100).toFixed(3)}%`; +} + +function renderMetrics(page: PdfVisualPageComparison): string { + if (!page.metrics) { + return '

无可比较的成对栅格。

'; + } + const metrics = page.metrics; + return `
+
MAE${metrics.meanAbsoluteError.toFixed(3)}
+
变化像素${percentage(metrics.changedPixelRatio)}
+
墨迹 IoU${metrics.inkIou.toFixed(4)}
+
边缘 IoU${metrics.edgeIou.toFixed(4)}
+
`; +} + +function renderArtifacts(page: PdfVisualPageComparison): string { + if (page.unpairedPng) { + return `
+
未配对页面
未配对页面
+
`; + } + const artifacts = page.artifacts; + if (!artifacts) { + return ""; + } + return `
+
基线
基线页面
+
候选
候选页面
+
+
+
红/青叠加
叠加差异
+
差异热力图
差异热力图
+
`; +} + +function pageTitle(page: PdfVisualPageComparison): string { + if (page.pair.status === "paired") { + return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页`; + } + if (page.pair.status === "baseline-only") { + return `基线第 ${page.pair.baselinePageNumber} 页无配对`; + } + return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`; +} + +export function renderPdfVisualDiffHtml( + report: PdfVisualDiffReport, +): string { + const issueRows = report.issues.length + ? report.issues + .map( + (issue) => + `${escapeHtml(issue.severity)}${escapeHtml(issue.code)}${escapeHtml(issue.message)}`, + ) + .join("") + : '没有发现门禁问题。'; + const pages = report.pages + .map( + (page, index) => `
+

${index + 1}. ${escapeHtml(pageTitle(page))} ${page.status}

+ ${renderMetrics(page)} + ${renderArtifacts(page)} +
`, + ) + .join(""); + return ` + + + + + PDF 视觉差异报告 + + +
+
+

PDF 视觉差异报告 ${report.status}

+

基线:${escapeHtml(report.basic.baseline.label)} 候选:${escapeHtml(report.basic.candidate.label)}

+

生成时间:${escapeHtml(report.generatedAt)} 正文相似度:${percentage(report.basic.contentSimilarity)}

+ ${issueRows}
级别代码说明
+
+ ${pages} +
`; +} + +export function serializePdfVisualDiffJson( + report: PdfVisualDiffReport, + indentation = 2, +): string { + return JSON.stringify( + report, + (key, value: unknown) => { + if ( + key.endsWith("Png") && + value instanceof Uint8Array + ) { + return { byteLength: value.byteLength }; + } + return value; + }, + indentation, + ); +} diff --git a/packages/document-visual-diff/src/snapshot.ts b/packages/document-visual-diff/src/snapshot.ts new file mode 100644 index 0000000..9cd2b83 --- /dev/null +++ b/packages/document-visual-diff/src/snapshot.ts @@ -0,0 +1,218 @@ +import { createHash } from "node:crypto"; + +import { createCanvas } from "@napi-rs/canvas"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; + +import { aggregatePdfTextLines, buildPageContentText, normalizePdfText } from "./text.js"; +import type { + CreatePdfSnapshotOptions, + PdfDocumentSnapshot, + PdfPageSnapshot, + PdfSnapshotLimits, + PdfTextItemSnapshot, +} from "./types.js"; + +interface PdfJsTextItem { + str: string; + transform: number[]; + width: number; + height: number; + fontName: string; + hasEOL: boolean; +} + +export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = { + maxPdfBytes: 100 * 1024 * 1024, + maxPages: 500, + maxPagePixels: 40_000_000, + maxTotalPixels: 500_000_000, +}; + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function isTextItem(item: unknown): item is PdfJsTextItem { + return ( + typeof item === "object" && + item !== null && + "str" in item && + typeof item.str === "string" && + "transform" in item && + Array.isArray(item.transform) && + item.transform.every((value) => typeof value === "number") && + "width" in item && + typeof item.width === "number" && + "height" in item && + typeof item.height === "number" && + "fontName" in item && + typeof item.fontName === "string" && + "hasEOL" in item && + typeof item.hasEOL === "boolean" + ); +} + +function createTextItemSnapshot( + item: PdfJsTextItem, + pageHeightPt: number, +): PdfTextItemSnapshot | undefined { + const normalizedText = normalizePdfText(item.str); + if (!normalizedText) { + return undefined; + } + const x = item.transform[4] ?? 0; + const baselineFromBottom = item.transform[5] ?? 0; + const height = Math.max( + Number.isFinite(item.height) ? Math.abs(item.height) : 0, + Math.hypot(item.transform[2] ?? 0, item.transform[3] ?? 0), + 0.1, + ); + const width = Math.max( + Number.isFinite(item.width) ? Math.abs(item.width) : 0, + 0, + ); + const baselineY = pageHeightPt - baselineFromBottom; + return { + text: item.str, + normalizedText, + bounds: { + x, + y: baselineY - height, + width, + height, + }, + baselineY, + fontName: item.fontName, + hasEol: item.hasEOL, + }; +} + +function resolveLimits( + input: Partial | undefined, +): PdfSnapshotLimits { + const limits = { ...DEFAULT_PDF_SNAPSHOT_LIMITS, ...input }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`PDF 快照限制 ${name} 必须是正数`); + } + } + return limits; +} + +export async function createPdfDocumentSnapshot( + pdfBytes: Uint8Array, + options: CreatePdfSnapshotOptions, +): Promise { + const limits = resolveLimits(options.limits); + if (pdfBytes.byteLength === 0) { + throw new Error("PDF 快照输入不能为空"); + } + if (pdfBytes.byteLength > limits.maxPdfBytes) { + throw new Error( + `PDF 快照输入超过限制:${pdfBytes.byteLength} > ${limits.maxPdfBytes}`, + ); + } + const dpi = options.dpi ?? 144; + if (!Number.isFinite(dpi) || dpi < 72 || dpi > 600) { + throw new Error("PDF 快照 DPI 必须位于 72 到 600 之间"); + } + const includeRaster = options.includeRaster ?? true; + const loadingTask = getDocument({ + data: Uint8Array.from(pdfBytes), + useSystemFonts: false, + verbosity: 0, + }); + let totalPixels = 0; + try { + const document = await loadingTask.promise; + if (document.numPages > limits.maxPages) { + throw new Error( + `PDF 页数超过限制:${document.numPages} > ${limits.maxPages}`, + ); + } + const pages: PdfPageSnapshot[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const pointViewport = page.getViewport({ scale: 1 }); + const textContent = await page.getTextContent({ + disableNormalization: false, + includeMarkedContent: false, + }); + const items = textContent.items.flatMap((item) => { + if (!isTextItem(item)) { + return []; + } + const snapshot = createTextItemSnapshot(item, pointViewport.height); + return snapshot ? [snapshot] : []; + }); + const lines = aggregatePdfTextLines(items, pointViewport.height); + const pageSnapshot: PdfPageSnapshot = { + pageNumber, + widthPt: pointViewport.width, + heightPt: pointViewport.height, + rotation: pointViewport.rotation, + items, + lines, + contentText: buildPageContentText(lines), + }; + const pageNumberLine = lines.find((line) => line.role === "page-number"); + if (pageNumberLine) { + pageSnapshot.pageNumberText = pageNumberLine.normalizedText; + } + + if (includeRaster) { + const renderViewport = page.getViewport({ scale: dpi / 72 }); + const widthPx = Math.ceil(renderViewport.width); + const heightPx = Math.ceil(renderViewport.height); + const pagePixels = widthPx * heightPx; + if (pagePixels > limits.maxPagePixels) { + throw new Error( + `第 ${pageNumber} 页像素数超过限制:${pagePixels} > ${limits.maxPagePixels}`, + ); + } + totalPixels += pagePixels; + if (totalPixels > limits.maxTotalPixels) { + throw new Error( + `PDF 总像素数超过限制:${totalPixels} > ${limits.maxTotalPixels}`, + ); + } + const canvas = createCanvas(widthPx, heightPx); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, widthPx, heightPx); + await page.render({ + canvas: null, + canvasContext: + context as unknown as CanvasRenderingContext2D, + viewport: renderViewport, + background: "rgb(255,255,255)", + intent: "print", + }).promise; + const png = canvas.toBuffer("image/png"); + pageSnapshot.raster = { + widthPx, + heightPx, + dpi, + sha256: sha256(png), + png: Uint8Array.from(png), + }; + } + page.cleanup(); + pages.push(pageSnapshot); + } + const contentText = pages + .map((page) => page.contentText) + .filter(Boolean) + .join("\n\f\n"); + return { + schemaVersion: 1, + source: options.source, + sha256: sha256(pdfBytes), + pageCount: pages.length, + contentText, + pages, + }; + } finally { + await loadingTask.destroy(); + } +} diff --git a/packages/document-visual-diff/src/text.ts b/packages/document-visual-diff/src/text.ts new file mode 100644 index 0000000..1946aaa --- /dev/null +++ b/packages/document-visual-diff/src/text.ts @@ -0,0 +1,158 @@ +import type { + PdfPointBounds, + PdfTextItemSnapshot, + PdfTextLineSnapshot, +} from "./types.js"; + +const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu; +const PAGE_NUMBER_PATTERNS = [ + /^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u, + /^第\s*\d+\s*页(?:\s*(?:[//]|共)\s*\d+\s*页)?$/u, + /^\d+\s*页(?:\s*(?:[//]|共)\s*\d+\s*页)?$/u, +]; + +export function normalizePdfText(value: string): string { + return value + .normalize("NFKC") + .replace(ZERO_WIDTH_AND_CONTROL, "") + .replace(/\u00a0/gu, " ") + .replace(/[ \t]+/gu, " ") + .trim(); +} + +export function normalizePdfContentText(value: string): string { + return normalizePdfText(value).replace(/\s+/gu, ""); +} + +function unionBounds(items: readonly PdfTextItemSnapshot[]): PdfPointBounds { + const left = Math.min(...items.map((item) => item.bounds.x)); + const top = Math.min(...items.map((item) => item.bounds.y)); + const right = Math.max( + ...items.map((item) => item.bounds.x + item.bounds.width), + ); + const bottom = Math.max( + ...items.map((item) => item.bounds.y + item.bounds.height), + ); + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +} + +function median(values: readonly number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const value = sorted[middle] ?? 0; + if (sorted.length % 2 === 1) { + return value; + } + return ((sorted[middle - 1] ?? value) + value) / 2; +} + +function joinLineItems(items: readonly PdfTextItemSnapshot[]): string { + let result = ""; + let previous: PdfTextItemSnapshot | undefined; + for (const item of items) { + const text = item.normalizedText; + if (!text) { + continue; + } + if (previous && result) { + const gap = + item.bounds.x - (previous.bounds.x + previous.bounds.width); + const referenceHeight = Math.max( + 1, + Math.min(previous.bounds.height, item.bounds.height), + ); + if ( + gap > referenceHeight * 0.24 && + !result.endsWith(" ") && + !text.startsWith(" ") + ) { + result += " "; + } + } + result += text; + previous = item; + } + return normalizePdfText(result); +} + +export function isPageNumberLine( + lineText: string, + bounds: PdfPointBounds, + pageHeightPt: number, +): boolean { + const normalized = normalizePdfText(lineText); + if (!PAGE_NUMBER_PATTERNS.some((pattern) => pattern.test(normalized))) { + return false; + } + const lineMiddle = bounds.y + bounds.height / 2; + const topBand = pageHeightPt * 0.08; + const bottomBand = pageHeightPt * 0.85; + return lineMiddle <= topBand || lineMiddle >= bottomBand; +} + +export function aggregatePdfTextLines( + sourceItems: readonly PdfTextItemSnapshot[], + pageHeightPt: number, +): PdfTextLineSnapshot[] { + const items = sourceItems + .filter((item) => item.normalizedText.length > 0) + .sort( + (left, right) => + left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x, + ); + const typicalHeight = median(items.map((item) => item.bounds.height)); + const baselineTolerance = Math.max(1.5, typicalHeight * 0.15); + const groups: PdfTextItemSnapshot[][] = []; + + for (const item of items) { + const lastGroup = groups.at(-1); + const lastBaseline = lastGroup + ? median(lastGroup.map((entry) => entry.baselineY)) + : undefined; + if ( + lastGroup && + lastBaseline !== undefined && + Math.abs(item.baselineY - lastBaseline) <= baselineTolerance + ) { + lastGroup.push(item); + } else { + groups.push([item]); + } + } + + return groups.map((group) => { + const sortedItems = [...group].sort( + (left, right) => left.bounds.x - right.bounds.x, + ); + const bounds = unionBounds(sortedItems); + const normalizedText = joinLineItems(sortedItems); + return { + text: normalizedText, + normalizedText, + bounds, + baselineY: median(sortedItems.map((item) => item.baselineY)), + role: isPageNumberLine(normalizedText, bounds, pageHeightPt) + ? "page-number" + : "content", + items: sortedItems, + }; + }); +} + +export function buildPageContentText( + lines: readonly PdfTextLineSnapshot[], +): string { + return lines + .filter((line) => line.role === "content") + .map((line) => line.normalizedText) + .filter(Boolean) + .join("\n"); +} diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts new file mode 100644 index 0000000..722e9fd --- /dev/null +++ b/packages/document-visual-diff/src/types.ts @@ -0,0 +1,189 @@ +export type PdfSourceKind = "chromium" | "word" | "wps" | "custom"; + +export interface PdfSnapshotSource { + kind: PdfSourceKind; + label: string; + fileName?: string; +} + +export interface PdfPointBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface PdfTextItemSnapshot { + text: string; + normalizedText: string; + bounds: PdfPointBounds; + baselineY: number; + fontName?: string; + hasEol: boolean; +} + +export type PdfTextLineRole = "content" | "page-number"; + +export interface PdfTextLineSnapshot { + text: string; + normalizedText: string; + bounds: PdfPointBounds; + baselineY: number; + role: PdfTextLineRole; + items: PdfTextItemSnapshot[]; +} + +export interface PdfPageRaster { + widthPx: number; + heightPx: number; + dpi: number; + sha256: string; + png: Uint8Array; +} + +export interface PdfPageSnapshot { + pageNumber: number; + widthPt: number; + heightPt: number; + rotation: number; + items: PdfTextItemSnapshot[]; + lines: PdfTextLineSnapshot[]; + contentText: string; + pageNumberText?: string; + raster?: PdfPageRaster; +} + +export interface PdfDocumentSnapshot { + schemaVersion: 1; + source: PdfSnapshotSource; + sha256: string; + pageCount: number; + contentText: string; + pages: PdfPageSnapshot[]; +} + +export interface PdfSnapshotLimits { + maxPdfBytes: number; + maxPages: number; + maxPagePixels: number; + maxTotalPixels: number; +} + +export interface CreatePdfSnapshotOptions { + source: PdfSnapshotSource; + dpi?: number; + includeRaster?: boolean; + limits?: Partial; +} + +export interface VisualDiffThresholds { + pageSizeDeltaPt: number; + contentSimilarity: number; + strictPageCount: boolean; + pixelDifferenceThreshold: number; + maxMeanAbsoluteError: number; + maxChangedPixelRatio: number; + minInkIou: number; + minEdgeIou: number; +} + +export type VisualDiffIssueSeverity = "warning" | "failure"; + +export type VisualDiffIssueCode = + | "PAGE_COUNT_MISMATCH" + | "UNPAIRED_BASELINE_PAGE" + | "UNPAIRED_CANDIDATE_PAGE" + | "PAGE_SIZE_MISMATCH" + | "PAGE_ORIENTATION_MISMATCH" + | "CONTENT_MISMATCH" + | "RASTER_MISSING" + | "RASTER_SIZE_MISMATCH" + | "PIXEL_MAE_EXCEEDED" + | "CHANGED_PIXEL_RATIO_EXCEEDED" + | "INK_IOU_BELOW_THRESHOLD" + | "EDGE_IOU_BELOW_THRESHOLD"; + +export interface VisualDiffIssue { + code: VisualDiffIssueCode; + severity: VisualDiffIssueSeverity; + message: string; + baselinePageNumber?: number; + candidatePageNumber?: number; + details?: Record; +} + +export type PdfPagePair = + | { + baselinePageNumber: number; + candidatePageNumber: number; + status: "paired"; + } + | { + baselinePageNumber: number; + status: "baseline-only"; + } + | { + candidatePageNumber: number; + status: "candidate-only"; + }; + +export interface PdfBasicComparison { + schemaVersion: 1; + status: "passed" | "warning" | "failed"; + baseline: PdfSnapshotSource; + candidate: PdfSnapshotSource; + thresholds: VisualDiffThresholds; + contentSimilarity: number; + pagePairs: PdfPagePair[]; + issues: VisualDiffIssue[]; +} + +export interface PdfRasterDiffMetrics { + comparedWidthPx: number; + comparedHeightPx: number; + dimensionsMatch: boolean; + meanAbsoluteError: number; + changedPixelRatio: number; + inkIou: number; + edgeIou: number; +} + +export interface PdfRasterDiffArtifacts { + baselinePng: Uint8Array; + candidatePng: Uint8Array; + overlayPng: Uint8Array; + heatmapPng: Uint8Array; + baselineSha256: string; + candidateSha256: string; + overlaySha256: string; + heatmapSha256: string; +} + +export interface PdfRasterDiffResult { + metrics: PdfRasterDiffMetrics; + artifacts: PdfRasterDiffArtifacts; +} + +export interface PdfVisualPageComparison { + pair: PdfPagePair; + status: "passed" | "warning" | "failed"; + metrics?: PdfRasterDiffMetrics; + artifacts?: PdfRasterDiffArtifacts; + unpairedPng?: Uint8Array; + unpairedSha256?: string; + issues: VisualDiffIssue[]; +} + +export interface PdfVisualDiffReport { + schemaVersion: 1; + generatedAt: string; + status: "passed" | "warning" | "failed"; + basic: PdfBasicComparison; + pages: PdfVisualPageComparison[]; + issues: VisualDiffIssue[]; +} + +export interface CreatePdfVisualDiffOptions { + thresholds?: Partial; + generatedAt?: string; +} diff --git a/packages/document-visual-diff/tests/adapters.test.ts b/packages/document-visual-diff/tests/adapters.test.ts new file mode 100644 index 0000000..951db67 --- /dev/null +++ b/packages/document-visual-diff/tests/adapters.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + createChromiumPdfAdapter, + createWordPdfAdapter, + createWpsPdfAdapter, + preparePdfAdapterRun, + runPdfAdapterVisualDiff, + type OfficeAutomationBackend, +} from "../src/index.js"; +import { createMinimalPdf } from "./pdf-fixture.js"; + +describe("PDF 产物适配器", () => { + it("Chromium 适配器复用调用方生成链并校验 PDF", async () => { + const generate = vi.fn(async () => ({ + pdf: createMinimalPdf("Chromium"), + pageCount: 1, + diagnostics: ["ok"], + })); + const adapter = createChromiumPdfAdapter({ + label: "统一 Chromium", + generate, + }); + expect(await adapter.probe()).toMatchObject({ + available: true, + adapterId: "chromium", + }); + const result = await adapter.generate({ markdown: "# 标题" }); + expect(generate).toHaveBeenCalledWith({ markdown: "# 标题" }); + expect(result.source.kind).toBe("chromium"); + expect(result.pageCount).toBe(1); + expect(result.diagnostics).toEqual(["ok"]); + }); + + it("拒绝生产者返回非 PDF 字节", async () => { + const adapter = createChromiumPdfAdapter({ + generate: async () => new TextEncoder().encode("not a pdf"), + }); + await expect(adapter.generate(undefined)).rejects.toThrow( + "PDF 文件签名", + ); + }); +}); + +describe("Office PDF 适配器", () => { + it("分别使用 Word 与 WPS ProgID 并清理临时输出", async () => { + const directory = await mkdtemp( + join(tmpdir(), "visual-diff-office-test-"), + ); + const docxPath = join(directory, "fixture.docx"); + await writeFile(docxPath, "fixture"); + const calls: Array<{ + client: string; + progId: string; + outputPath: string; + }> = []; + const backend: OfficeAutomationBackend = { + probe: async () => true, + exportPdf: async (options) => { + const pdf = createMinimalPdf(options.client); + await writeFile(options.outputPath, pdf); + calls.push({ + client: options.client, + progId: options.progId, + outputPath: options.outputPath, + }); + return { bytes: pdf.byteLength, pageCount: 1 }; + }, + }; + try { + const word = createWordPdfAdapter({ backend }); + const wps = createWpsPdfAdapter({ backend }); + expect((await word.probe()).available).toBe(true); + expect((await wps.probe()).available).toBe(true); + expect((await word.generate({ docxPath })).source.kind).toBe("word"); + expect((await wps.generate({ docxPath })).source.kind).toBe("wps"); + expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([ + { client: "word", progId: "Word.Application" }, + { client: "wps", progId: "KWPS.Application" }, + ]); + for (const call of calls) { + await expect( + import("node:fs/promises").then(({ stat }) => + stat(call.outputPath), + ), + ).rejects.toThrow(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("PDF 适配器视觉编排", () => { + it("基线只生成一次并按顺序比较全部候选", async () => { + const order: string[] = []; + const adapter = (label: string) => + createChromiumPdfAdapter({ + label, + generate: async () => { + order.push(label); + return createMinimalPdf("same"); + }, + }); + const result = await runPdfAdapterVisualDiff({ + baseline: preparePdfAdapterRun(adapter("baseline"), undefined), + candidates: [ + preparePdfAdapterRun(adapter("candidate-1"), undefined), + preparePdfAdapterRun(adapter("candidate-2"), undefined), + ], + snapshot: { includeRaster: false }, + }); + expect(order).toEqual(["baseline", "candidate-1", "candidate-2"]); + expect(result.candidates).toHaveLength(2); + expect( + result.candidates.map(({ report }) => report.status), + ).toEqual(["failed", "failed"]); + expect( + result.candidates.flatMap(({ report }) => + report.issues.map((issue) => issue.code), + ), + ).toEqual(["RASTER_MISSING", "RASTER_MISSING"]); + }); +}); diff --git a/packages/document-visual-diff/tests/compare.test.ts b/packages/document-visual-diff/tests/compare.test.ts new file mode 100644 index 0000000..e78a616 --- /dev/null +++ b/packages/document-visual-diff/tests/compare.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + comparePdfSnapshotsBasic, + createPdfDocumentSnapshot, + type PdfDocumentSnapshot, +} from "../src/index.js"; +import { createMinimalPdf } from "./pdf-fixture.js"; + +async function snapshot( + label: string, + text = "Visual diff fixture", + width = 595, + height = 842, +): Promise { + return createPdfDocumentSnapshot(createMinimalPdf(text, width, height), { + source: { kind: "custom", label }, + includeRaster: false, + }); +} + +describe("PDF 基础视觉门禁", () => { + it("通过纸张和正文一致的文档", async () => { + const baseline = await snapshot("baseline"); + const candidate = await snapshot("candidate"); + const result = comparePdfSnapshotsBasic(baseline, candidate); + expect(result.status).toBe("passed"); + expect(result.contentSimilarity).toBe(1); + expect(result.issues).toEqual([]); + }); + + it("拒绝纸张尺寸和正文内容偏差", async () => { + const baseline = await snapshot("baseline"); + const candidate = await snapshot("candidate", "Different content", 612); + const result = comparePdfSnapshotsBasic(baseline, candidate); + expect(result.status).toBe("failed"); + expect(result.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining(["PAGE_SIZE_MISMATCH", "CONTENT_MISMATCH"]), + ); + }); + + it("内容完整性比较不受重新换行影响", async () => { + const baseline = await snapshot("baseline", "same content"); + const candidate = await snapshot("candidate", "samecontent"); + const result = comparePdfSnapshotsBasic(baseline, candidate); + expect(result.contentSimilarity).toBe(1); + expect(result.status).toBe("passed"); + }); + + it("页数不一致时仍保留溢出页配对记录", async () => { + const baseline = await snapshot("baseline"); + const candidatePage = (await snapshot("candidate")).pages[0]; + if (!candidatePage) { + throw new Error("测试快照缺少页面"); + } + const candidate: PdfDocumentSnapshot = { + ...(await snapshot("candidate")), + pageCount: 2, + pages: [ + candidatePage, + { + ...candidatePage, + pageNumber: 2, + contentText: "", + items: [], + lines: [], + }, + ], + }; + const result = comparePdfSnapshotsBasic(baseline, candidate); + expect(result.pagePairs[1]).toEqual({ + candidatePageNumber: 2, + status: "candidate-only", + }); + expect(result.issues.map((issue) => issue.code)).toContain( + "UNPAIRED_CANDIDATE_PAGE", + ); + }); +}); diff --git a/packages/document-visual-diff/tests/pdf-fixture.ts b/packages/document-visual-diff/tests/pdf-fixture.ts new file mode 100644 index 0000000..3a12d14 --- /dev/null +++ b/packages/document-visual-diff/tests/pdf-fixture.ts @@ -0,0 +1,30 @@ +export function createMinimalPdf( + text = "Visual diff fixture", + pageWidth = 595, + pageHeight = 842, +): Uint8Array { + const escapedText = text.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)"); + const content = `BT /F1 18 Tf 72 ${pageHeight - 72} Td (${escapedText}) Tj ET`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + ]; + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`; + pdf += `startxref\n${xrefOffset}\n%%EOF\n`; + return new TextEncoder().encode(pdf); +} diff --git a/packages/document-visual-diff/tests/raster.test.ts b/packages/document-visual-diff/tests/raster.test.ts new file mode 100644 index 0000000..b7ae3e3 --- /dev/null +++ b/packages/document-visual-diff/tests/raster.test.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; + +import { createCanvas } from "@napi-rs/canvas"; +import { describe, expect, it } from "vitest"; + +import { + comparePageRasters, + type PdfPageRaster, +} from "../src/index.js"; + +function raster(rectangleX: number): PdfPageRaster { + const canvas = createCanvas(80, 100); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 80, 100); + context.fillStyle = "#111111"; + context.fillRect(rectangleX, 20, 20, 40); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 80, + heightPx: 100, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + }; +} + +describe("PDF 栅格差异", () => { + it("相同页面的全部指标为无差异", async () => { + const page = raster(10); + const result = await comparePageRasters(page, page); + expect(result.metrics).toMatchObject({ + dimensionsMatch: true, + meanAbsoluteError: 0, + changedPixelRatio: 0, + inkIou: 1, + edgeIou: 1, + }); + expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual( + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + ); + }); + + it("量化位移并生成稳定叠加图与热力图", async () => { + const result = await comparePageRasters(raster(10), raster(20)); + expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10); + expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2); + expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2); + expect(result.metrics.edgeIou).toBeLessThan(0.4); + expect(result.artifacts.overlaySha256).toHaveLength(64); + expect(result.artifacts.heatmapSha256).toHaveLength(64); + }); +}); diff --git a/packages/document-visual-diff/tests/report.test.ts b/packages/document-visual-diff/tests/report.test.ts new file mode 100644 index 0000000..a599f55 --- /dev/null +++ b/packages/document-visual-diff/tests/report.test.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; + +import { createCanvas } from "@napi-rs/canvas"; +import { describe, expect, it } from "vitest"; + +import { + createPdfVisualDiffReport, + renderPdfVisualDiffHtml, + serializePdfVisualDiffJson, + type PdfDocumentSnapshot, + type PdfPageRaster, +} from "../src/index.js"; + +function raster(color: string): PdfPageRaster { + const canvas = createCanvas(40, 50); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 40, 50); + context.fillStyle = color; + context.fillRect(10, 10, 20, 20); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 40, + heightPx: 50, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + }; +} + +function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot { + return { + schemaVersion: 1, + source: { kind: "custom", label }, + sha256: "a".repeat(64), + pageCount: 1, + contentText: "相同正文", + pages: [ + { + pageNumber: 1, + widthPt: 20, + heightPt: 25, + rotation: 0, + items: [], + lines: [], + contentText: "相同正文", + raster: pageRaster, + }, + ], + }; +} + +describe("PDF 视觉差异报告", () => { + it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => { + const report = await createPdfVisualDiffReport( + snapshot("<基线>", raster("#111111")), + snapshot("候选", raster("#cc0000")), + { generatedAt: "2026-07-31T00:00:00.000Z" }, + ); + const html = renderPdfVisualDiffHtml(report); + expect(report.status).toBe("failed"); + expect(html).toContain("<基线>"); + expect(html).toContain("data:image/png;base64,"); + expect(html).toContain("差异热力图"); + expect(html).not.toContain("<基线>"); + }); + + it("JSON 只保留图片字节数和稳定摘要", async () => { + const page = raster("#111111"); + const report = await createPdfVisualDiffReport( + snapshot("基线", page), + snapshot("候选", page), + ); + const json = serializePdfVisualDiffJson(report); + expect(json).toContain('"overlaySha256"'); + expect(json).toContain('"byteLength"'); + expect(json).not.toContain('"0": 137'); + }); + + it("在报告中展示未配对的溢出页面", async () => { + const baseline = snapshot("基线", raster("#111111")); + const candidatePage = snapshot("候选", raster("#111111")); + const candidate: PdfDocumentSnapshot = { + ...candidatePage, + pageCount: 2, + pages: [ + candidatePage.pages[0]!, + { + ...candidatePage.pages[0]!, + pageNumber: 2, + }, + ], + }; + const report = await createPdfVisualDiffReport(baseline, candidate); + const overflow = report.pages[1]; + expect(overflow?.pair.status).toBe("candidate-only"); + expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0); + expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面"); + }); +}); diff --git a/packages/document-visual-diff/tests/snapshot.test.ts b/packages/document-visual-diff/tests/snapshot.test.ts new file mode 100644 index 0000000..18ded23 --- /dev/null +++ b/packages/document-visual-diff/tests/snapshot.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { createPdfDocumentSnapshot } from "../src/index.js"; +import { createMinimalPdf } from "./pdf-fixture.js"; + +describe("PDF.js 页面快照", () => { + it("从真实 PDF 同时提取纸张、文本和稳定 PNG", async () => { + const pdf = createMinimalPdf(); + const first = await createPdfDocumentSnapshot(pdf, { + source: { kind: "custom", label: "fixture" }, + dpi: 144, + }); + const second = await createPdfDocumentSnapshot(pdf, { + source: { kind: "custom", label: "fixture" }, + dpi: 144, + }); + + expect(first.pageCount).toBe(1); + 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.pages[0]?.raster).toMatchObject({ + widthPx: 1190, + heightPx: 1684, + dpi: 144, + }); + expect(first.pages[0]?.raster?.png.byteLength).toBeGreaterThan(1000); + expect(first.pages[0]?.raster?.sha256).toBe( + second.pages[0]?.raster?.sha256, + ); + }); + + it("允许关闭栅格输出以执行轻量结构门禁", async () => { + const snapshot = await createPdfDocumentSnapshot(createMinimalPdf(), { + source: { kind: "custom", label: "fixture" }, + includeRaster: false, + }); + expect(snapshot.pages[0]?.raster).toBeUndefined(); + }); +}); diff --git a/packages/document-visual-diff/tests/text.test.ts b/packages/document-visual-diff/tests/text.test.ts new file mode 100644 index 0000000..c11d321 --- /dev/null +++ b/packages/document-visual-diff/tests/text.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + aggregatePdfTextLines, + buildPageContentText, + normalizePdfText, + type PdfTextItemSnapshot, +} from "../src/index.js"; + +function item( + text: string, + x: number, + y: number, + width = 20, + height = 10, +): PdfTextItemSnapshot { + return { + text, + normalizedText: normalizePdfText(text), + bounds: { x, y, width, height }, + baselineY: y + height, + hasEol: false, + }; +} + +describe("PDF 文本行聚合", () => { + it("按坐标聚合中文文本并规范化兼容字符", () => { + const lines = aggregatePdfTextLines( + [ + item("ABC", 10, 100, 30), + item("中文", 41, 100, 20), + item("第二行", 10, 120, 40), + ], + 842, + ); + expect(lines.map((line) => line.normalizedText)).toEqual([ + "ABC中文", + "第二行", + ]); + }); + + it("只在页眉页脚坐标带识别独立页码", () => { + const lines = aggregatePdfTextLines( + [ + item("章节 1", 72, 400, 50), + item("— 1 —", 280, 731, 35, 14), + ], + 842, + ); + expect(lines.map((line) => line.role)).toEqual([ + "content", + "page-number", + ]); + expect(buildPageContentText(lines)).toBe("章节 1"); + }); + + it("不会把页脚中的普通说明误判为页码", () => { + const lines = aggregatePdfTextLines( + [item("内部资料 1", 72, 800, 70)], + 842, + ); + expect(lines[0]?.role).toBe("content"); + }); +}); diff --git a/packages/document-visual-diff/tsconfig.json b/packages/document-visual-diff/tsconfig.json new file mode 100644 index 0000000..9960e15 --- /dev/null +++ b/packages/document-visual-diff/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": [ + "node" + ] + }, + "include": [ + "src" + ] +} diff --git a/packages/docx-engine/src/font-embedding.ts b/packages/docx-engine/src/font-embedding.ts index 17ee1e0..cc3dc2c 100644 --- a/packages/docx-engine/src/font-embedding.ts +++ b/packages/docx-engine/src/font-embedding.ts @@ -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 @@ -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"); diff --git a/packages/docx-engine/src/font-package-transform.ts b/packages/docx-engine/src/font-package-transform.ts index 15500e0..dad7205 100644 --- a/packages/docx-engine/src/font-package-transform.ts +++ b/packages/docx-engine/src/font-package-transform.ts @@ -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(); 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, diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts index b795a9b..d55c3a6 100644 --- a/packages/docx-engine/src/index.ts +++ b/packages/docx-engine/src/index.ts @@ -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"; diff --git a/packages/docx-engine/src/styles-transform.ts b/packages/docx-engine/src/styles-transform.ts index a09ba6e..5611a0f 100644 --- a/packages/docx-engine/src/styles-transform.ts +++ b/packages/docx-engine/src/styles-transform.ts @@ -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( diff --git a/packages/docx-engine/src/validator.ts b/packages/docx-engine/src/validator.ts index e0732c4..0d8488c 100644 --- a/packages/docx-engine/src/validator.ts +++ b/packages/docx-engine/src/validator.ts @@ -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; diff --git a/packages/docx-engine/src/word-font-metadata.ts b/packages/docx-engine/src/word-font-metadata.ts new file mode 100644 index 0000000..aa84db4 --- /dev/null +++ b/packages/docx-engine/src/word-font-metadata.ts @@ -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) + } + }; +} diff --git a/packages/docx-engine/tests/font-embedding.test.ts b/packages/docx-engine/tests/font-embedding.test.ts index 53d87d8..3773eb6 100644 --- a/packages/docx-engine/tests/font-embedding.test.ts +++ b/packages/docx-engine/tests/font-embedding.test.ts @@ -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); diff --git a/packages/docx-engine/tests/font-package-transform.test.ts b/packages/docx-engine/tests/font-package-transform.test.ts index cc6629e..6c61d2e 100644 --- a/packages/docx-engine/tests/font-package-transform.test.ts +++ b/packages/docx-engine/tests/font-package-transform.test.ts @@ -97,6 +97,15 @@ describe("DOCX 字体包写入", () => { output.entries.get("word/fontTable.xml") ); expect(fontTable).toContain('w:name="FandolFang"'); + expect(fontTable).toContain( + '' + ); + expect(fontTable).toContain(''); + expect(fontTable).toContain(''); + expect(fontTable).toContain(''); + expect(fontTable).toContain( + '' + ); expect(fontTable).toContain(" { expect(validateGeneratedDocx(result.content)).toMatchObject({ embeddedFontCount: 1 }); + + const invalidEntries = new Map(output.entries); + invalidEntries.set( + "word/fontTable.xml", + encoder.encode( + fontTable.replace( + '', + '' + ) + ) + ); + expect(() => + validateGeneratedDocx( + writeGeneratedDocxPackage(invalidEntries) + ) + ).toThrow("字体元数据与嵌入字形不匹配"); }); it("没有字体时保持原始 DOCX 字节不变", () => { diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index daba5a9..cd9ee98 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -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(''); 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:line="315"[^>]*w:lineRule="exact"/u + ); + expect(stylesXml).not.toContain('w:lineRule="auto"'); expect(stylesXml).not.toContain(" = {} +): 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"); + }); +});