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

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+28 -1
View File
@@ -251,6 +251,27 @@ Word/WPS 均为 2 页;红头主题在 Word 为 2 页、WPS 为 3 页,版记
写回器产生的 OOXML 顺序问题放宽自身严格门禁。完整记录见 写回器产生的 OOXML 顺序问题放宽自身严格门禁。完整记录见
[DOCX 字体嵌入与代表主题验收](V0.6.0_DOCX_FONT_EMBEDDING.md)。 [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. 已完成
### 2.1 项目骨架 ### 2.1 项目骨架
@@ -1132,6 +1153,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX - `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX
样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、 样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、
请求级临时转换和最终结构校验。 请求级临时转换和最终结构校验。
- `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与
栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和
发布验收,不进入应用运行时。
- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、
并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
@@ -1188,7 +1212,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
表格内容区全宽、分页控制、OOXML 严格性和 14 主题真实 Word/WPS 表格内容区全宽、分页控制、OOXML 严格性和 14 主题真实 Word/WPS
无修复打开门禁; 无修复打开门禁;
- 阶段 12D-R2:已完成可分发字体嵌入和三套代表主题真实 Word/WPS - 阶段 12D-R2:已完成可分发字体嵌入和三套代表主题真实 Word/WPS
编辑互存;下一步 R3 建立 PDF 与 DOCX 自动视觉差异引擎,再由 R4 完成 编辑互存;
- 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体
字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS
原生视觉与可编辑性验收;下一步先对齐可选字体包架构,再由 R4 完成
14 套主题深度视觉回归; 14 套主题深度视觉回归;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
+15 -1
View File
@@ -2187,6 +2187,10 @@
"resolved": "apps/desktop", "resolved": "apps/desktop",
"link": true "link": true
}, },
"node_modules/@md-to-pdf/document-visual-diff": {
"resolved": "packages/document-visual-diff",
"link": true
},
"node_modules/@md-to-pdf/docx-engine": { "node_modules/@md-to-pdf/docx-engine": {
"resolved": "packages/docx-engine", "resolved": "packages/docx-engine",
"link": true "link": true
@@ -2355,8 +2359,8 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz",
"integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"workspaces": [ "workspaces": [
"e2e/*" "e2e/*"
], ],
@@ -9978,6 +9982,16 @@
"vitest": "^4.1.10" "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": { "packages/docx-engine": {
"name": "@md-to-pdf/docx-engine", "name": "@md-to-pdf/docx-engine",
"version": "0.1.0", "version": "0.1.0",
+2 -2
View File
@@ -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-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-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", "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", "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/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" "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": { "engines": {
"node": ">=22" "node": ">=22"
@@ -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"
}
}
@@ -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<PdfAdapterGeneration>;
}
export interface PdfAdapterVisualDiffResult {
baseline: PdfAdapterGeneration;
baselineSnapshot: PdfDocumentSnapshot;
candidates: Array<{
generation: PdfAdapterGeneration;
snapshot: PdfDocumentSnapshot;
report: PdfVisualDiffReport;
}>;
}
export interface RunPdfAdapterVisualDiffOptions {
baseline: PreparedPdfAdapterRun;
candidates: PreparedPdfAdapterRun[];
snapshot?: Omit<CreatePdfSnapshotOptions, "source">;
report?: CreatePdfVisualDiffOptions;
}
export function preparePdfAdapterRun<TInput>(
adapter: PdfArtifactAdapter<TInput>,
input: TInput,
): PreparedPdfAdapterRun {
return {
id: adapter.id,
generate: () => adapter.generate(input),
};
}
export async function runPdfAdapterVisualDiff(
options: RunPdfAdapterVisualDiffOptions,
): Promise<PdfAdapterVisualDiffResult> {
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,
};
}
@@ -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<TInput> {
readonly id: string;
readonly source: PdfSnapshotSource;
probe(): Promise<PdfAdapterCapability>;
generate(input: TInput): Promise<PdfAdapterGeneration>;
}
export type ChromiumPdfProducerResult =
| Uint8Array
| {
pdf: Uint8Array;
pageCount?: number;
diagnostics?: string[];
};
export interface ChromiumPdfAdapterOptions<TInput> {
label?: string;
fileName?: string;
generate: (input: TInput) => Promise<ChromiumPdfProducerResult>;
}
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<TInput>(
options: ChromiumPdfAdapterOptions<TInput>,
): PdfArtifactAdapter<TInput> {
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 ?? [],
};
},
};
}
@@ -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<VisualDiffThresholds> = {},
): 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,
};
}
@@ -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";
@@ -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<boolean>;
exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata>;
}
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<string, unknown> {
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<string, unknown>;
}
export class PowerShellOfficeAutomationBackend
implements OfficeAutomationBackend
{
async probe(progId: string, timeoutMs: number): Promise<boolean> {
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<OfficeExportMetadata> {
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<string> {
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<OfficePdfAdapterInput> {
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<PdfAdapterCapability> {
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<PdfAdapterGeneration> {
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<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "word",
client: "word",
progId: "Word.Application",
defaultLabel: "Microsoft Word",
},
options,
);
}
export function createWpsPdfAdapter(
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "wps",
client: "wps",
progId: "KWPS.Application",
defaultLabel: "WPS Writer",
},
options,
);
}
+255
View File
@@ -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<DecodedRaster> {
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<PdfRasterDiffResult> {
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,
),
};
}
+296
View File
@@ -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<PdfPagePair, { status: "paired" }>,
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
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<PdfVisualPageComparison> {
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<PdfVisualDiffReport> {
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 '<p class="muted">无可比较的成对栅格。</p>';
}
const metrics = page.metrics;
return `<div class="metrics">
<div><span>MAE</span><strong>${metrics.meanAbsoluteError.toFixed(3)}</strong></div>
<div><span>变化像素</span><strong>${percentage(metrics.changedPixelRatio)}</strong></div>
<div><span>墨迹 IoU</span><strong>${metrics.inkIou.toFixed(4)}</strong></div>
<div><span>边缘 IoU</span><strong>${metrics.edgeIou.toFixed(4)}</strong></div>
</div>`;
}
function renderArtifacts(page: PdfVisualPageComparison): string {
if (page.unpairedPng) {
return `<div class="unpaired">
<figure><figcaption>未配对页面</figcaption><img src="${pngDataUri(page.unpairedPng)}" alt="未配对页面"></figure>
</div>`;
}
const artifacts = page.artifacts;
if (!artifacts) {
return "";
}
return `<div class="side-by-side">
<figure><figcaption>基线</figcaption><img src="${pngDataUri(artifacts.baselinePng)}" alt="基线页面"></figure>
<figure><figcaption>候选</figcaption><img src="${pngDataUri(artifacts.candidatePng)}" alt="候选页面"></figure>
</div>
<div class="side-by-side">
<figure><figcaption>红/青叠加</figcaption><img src="${pngDataUri(artifacts.overlayPng)}" alt="叠加差异"></figure>
<figure><figcaption>差异热力图</figcaption><img src="${pngDataUri(artifacts.heatmapPng)}" alt="差异热力图"></figure>
</div>`;
}
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) =>
`<tr><td>${escapeHtml(issue.severity)}</td><td>${escapeHtml(issue.code)}</td><td>${escapeHtml(issue.message)}</td></tr>`,
)
.join("")
: '<tr><td colspan="3">没有发现门禁问题。</td></tr>';
const pages = report.pages
.map(
(page, index) => `<section class="page-card">
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
${renderMetrics(page)}
${renderArtifacts(page)}
</section>`,
)
.join("");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF 视觉差异报告</title>
<style>
:root{font-family:Inter,"Microsoft YaHei",sans-serif;color:#172033;background:#f3f5f8}
*{box-sizing:border-box}body{margin:0}main{max-width:1500px;margin:auto;padding:28px}
h1,h2{margin:0 0 16px}h2{font-size:18px}.summary,.page-card{background:#fff;border:1px solid #dfe4ec;border-radius:12px;padding:20px;margin-bottom:20px;box-shadow:0 3px 12px #1720330d}
.status{display:inline-block;border-radius:999px;padding:4px 10px;font-size:12px;text-transform:uppercase}
.passed{background:#dff7e8;color:#126636}.warning{background:#fff1c7;color:#805d00}.failed{background:#ffe0e0;color:#9a1b1b}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin-bottom:16px}
.metrics div{padding:12px;background:#f7f9fc;border-radius:8px}.metrics span{display:block;color:#667085;font-size:12px}.metrics strong{font-size:20px}
.side-by-side{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.unpaired{max-width:720px;margin-top:16px}
figure{margin:0;min-width:0}figcaption{margin-bottom:8px;color:#475467;font-weight:600}
img{display:block;width:100%;height:auto;border:1px solid #d0d5dd;background:#fff}
table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #e4e7ec;text-align:left;vertical-align:top}
.muted{color:#667085}@media(max-width:800px){main{padding:12px}.metrics,.side-by-side{grid-template-columns:1fr}}
</style>
</head>
<body><main>
<section class="summary">
<h1>PDF 视觉差异报告 <span class="status ${report.status}">${report.status}</span></h1>
<p><strong>基线:</strong>${escapeHtml(report.basic.baseline.label)} <strong>候选:</strong>${escapeHtml(report.basic.candidate.label)}</p>
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}
</main></body></html>`;
}
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,
);
}
@@ -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<PdfSnapshotLimits> | 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<PdfDocumentSnapshot> {
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();
}
}
+158
View File
@@ -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");
}
+189
View File
@@ -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<PdfSnapshotLimits>;
}
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<string, number | string | boolean>;
}
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<VisualDiffThresholds>;
generatedAt?: string;
}
@@ -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"]);
});
});
@@ -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<PdfDocumentSnapshot> {
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",
);
});
});
@@ -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);
}
@@ -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);
});
});
@@ -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("&lt;基线&gt;");
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("未配对页面");
});
});
@@ -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();
});
});
@@ -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");
});
});
@@ -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"
]
}
+161 -2
View File
@@ -10,6 +10,7 @@ export const MAXIMUM_DOCX_FONT_SFNT_BYTES = 16 * 1024 * 1024;
export const MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES = export const MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES =
48 * 1024 * 1024; 48 * 1024 * 1024;
const MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES = 8; const MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES = 8;
const SFNT_CHECKSUM_MAGIC = 0xb1b0afba;
const fontConversionCache = new Map< const fontConversionCache = new Map<
string, string,
Promise<Uint8Array> Promise<Uint8Array>
@@ -42,6 +43,21 @@ export interface SfntFontMetadata {
subfamily: string; subfamily: string;
postscriptName?: string; postscriptName?: string;
weightClass: number; 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; fsType: number;
permission: DocxFontEmbeddingPermission; permission: DocxFontEmbeddingPermission;
noSubsetting: boolean; noSubsetting: boolean;
@@ -64,6 +80,7 @@ export interface ObfuscatedDocxFont {
interface SfntTable { interface SfntTable {
offset: number; offset: number;
length: number; length: number;
recordOffset: number;
} }
function readTag(content: Uint8Array, offset: number) { function readTag(content: Uint8Array, offset: number) {
@@ -160,7 +177,7 @@ function readSfntTables(content: Uint8Array) {
if (tables.has(tag)) { if (tables.has(tag)) {
throw new Error(`SFNT 字体包含重复表:${tag}`); throw new Error(`SFNT 字体包含重复表:${tag}`);
} }
tables.set(tag, { offset, length }); tables.set(tag, { offset, length, recordOffset });
} }
return { 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) { function decodeUtf16Be(content: Uint8Array) {
if (content.byteLength % 2 !== 0) { if (content.byteLength % 2 !== 0) {
throw new Error("字体名称 UTF-16BE 长度无效"); throw new Error("字体名称 UTF-16BE 长度无效");
@@ -334,12 +425,16 @@ export function readSfntFontMetadata(
const { signature, tables, view } = readSfntTables(content); const { signature, tables, view } = readSfntTables(content);
const os2 = tables.get("OS/2"); const os2 = tables.get("OS/2");
const name = tables.get("name"); 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 表"); throw new Error("SFNT 字体缺少完整 OS/2 表");
} }
if (!name) { if (!name) {
throw new Error("SFNT 字体缺少 name 表"); throw new Error("SFNT 字体缺少 name 表");
} }
if (post && post.length < 16) {
throw new Error("SFNT 字体 post 表不完整");
}
const family = readFontName(content, name, [16, 1]); const family = readFontName(content, name, [16, 1]);
const subfamily = readFontName(content, name, [17, 2]); const subfamily = readFontName(content, name, [17, 2]);
@@ -354,6 +449,52 @@ export function readSfntFontMetadata(
); );
const permission = resolveFontEmbeddingPermission(fsType); const permission = resolveFontEmbeddingPermission(fsType);
const postscriptName = readFontName(content, name, [6]); 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 { return {
family, family,
@@ -365,6 +506,18 @@ export function readSfntFontMetadata(
os2.offset + 4, os2.offset + 4,
"OS/2 usWeightClass" "OS/2 usWeightClass"
), ),
panose,
unicodeRanges,
codePageRanges,
fixedPitch:
post === undefined
? false
: readUint32(
view,
content,
post.offset + 12,
"post isFixedPitch"
) !== 0,
fsType, fsType,
permission, permission,
noSubsetting: (fsType & 0x0100) !== 0, noSubsetting: (fsType & 0x0100) !== 0,
@@ -423,7 +576,13 @@ export async function prepareDocxFont(
}); });
} }
const sfnt = (await conversion).slice(); const sfnt = (await conversion).slice();
normalizeSfntWeightClass(sfnt, source.weight);
const metadata = readSfntFontMetadata(sfnt); const metadata = readSfntFontMetadata(sfnt);
if (metadata.weightClass !== source.weight) {
throw new Error(
`字体内部权重 ${metadata.weightClass} 与声明权重 ${source.weight} 不一致`
);
}
const fingerprint = createHash("sha256") const fingerprint = createHash("sha256")
.update(sfnt) .update(sfnt)
.digest("hex"); .digest("hex");
@@ -19,6 +19,7 @@ import {
readGeneratedDocxPackage, readGeneratedDocxPackage,
writeGeneratedDocxPackage writeGeneratedDocxPackage
} from "./reference-package.js"; } from "./reference-package.js";
import { createWordFontMetadata } from "./word-font-metadata.js";
const FONT_RELATIONSHIP_TYPE = const FONT_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
@@ -318,17 +319,49 @@ function updateFontTable(
font font
]) ])
); );
const metadataFamilies = new Set<string>();
for (const face of faces) { for (const face of faces) {
const family = face.font.metadata.family; const family = face.font.metadata.family;
let font = byName.get(normalizeFontName(family)); const normalizedFamily = normalizeFontName(family);
let font = byName.get(normalizedFamily);
if (!font) { if (!font) {
font = appendElement(root, WORD_NAMESPACE, "w:font", { font = appendElement(root, WORD_NAMESPACE, "w:font", {
"w:name": family "w:name": family
}); });
appendElement(font, WORD_NAMESPACE, "w:family", { byName.set(normalizedFamily, font);
"w:val": "auto" }
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}`, { appendElement(font, WORD_NAMESPACE, `w:${face.role}`, {
"r:id": face.relationshipId, "r:id": face.relationshipId,
+1
View File
@@ -18,3 +18,4 @@ export * from "./style-presets.js";
export * from "./styles-transform.js"; export * from "./styles-transform.js";
export * from "./token-style-map.js"; export * from "./token-style-map.js";
export * from "./validator.js"; export * from "./validator.js";
export * from "./word-font-metadata.js";
+56 -7
View File
@@ -108,6 +108,7 @@ function setParagraphSpacing(
options: { options: {
beforePt: number; beforePt: number;
afterPt: number; afterPt: number;
fontSizePt: number;
lineSpacing: number; lineSpacing: number;
firstLineIndentChars?: number; firstLineIndentChars?: number;
} }
@@ -116,8 +117,10 @@ function setParagraphSpacing(
appendElement(parent, WORD_NAMESPACE, "w:spacing", { appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:before": pointsToTwips(options.beforePt), "w:before": pointsToTwips(options.beforePt),
"w:after": pointsToTwips(options.afterPt), "w:after": pointsToTwips(options.afterPt),
"w:line": String(Math.round(options.lineSpacing * 240)), "w:line": pointsToTwips(
"w:lineRule": "auto" options.fontSizePt * options.lineSpacing
),
"w:lineRule": "exact"
}); });
if (options.firstLineIndentChars !== undefined) { if (options.firstLineIndentChars !== undefined) {
appendElement(parent, WORD_NAMESPACE, "w:ind", { appendElement(parent, WORD_NAMESPACE, "w:ind", {
@@ -208,6 +211,38 @@ function fallbackFontsForSlot(
return fallback.body.fonts; 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( function applyTokenRunStyle(
parent: XmlElement, parent: XmlElement,
token: DocxSlotStyleToken, token: DocxSlotStyleToken,
@@ -306,7 +341,8 @@ function applyTokenBorders(
function applyTokenParagraphStyle( function applyTokenParagraphStyle(
parent: XmlElement, parent: XmlElement,
token: DocxSlotStyleToken token: DocxSlotStyleToken,
fallbackFontSizePt: number
) { ) {
if ( if (
token.spacingBeforePt !== undefined || token.spacingBeforePt !== undefined ||
@@ -336,9 +372,12 @@ function applyTokenParagraphStyle(
setWordAttribute( setWordAttribute(
spacing, spacing,
"line", "line",
String(Math.round(token.lineSpacing * 240)) pointsToTwips(
(token.fontSizePt ?? fallbackFontSizePt) *
token.lineSpacing
)
); );
setWordAttribute(spacing, "lineRule", "auto"); setWordAttribute(spacing, "lineRule", "exact");
} }
} }
if ( if (
@@ -440,6 +479,7 @@ function applyBodyStyle(
setParagraphSpacing(defaultParagraph, { setParagraphSpacing(defaultParagraph, {
beforePt: style.body.spacingBeforePt, beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt, afterPt: style.body.spacingAfterPt,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing, lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars firstLineIndentChars: style.body.firstLineIndentChars
}); });
@@ -466,6 +506,7 @@ function applyBodyStyle(
setParagraphSpacing(pPr, { setParagraphSpacing(pPr, {
beforePt: style.body.spacingBeforePt, beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt, afterPt: style.body.spacingAfterPt,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing, lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars firstLineIndentChars: style.body.firstLineIndentChars
}); });
@@ -492,6 +533,7 @@ function applyBodyStyle(
{ {
beforePt: 0, beforePt: 0,
afterPt: 0, afterPt: 0,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing, lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0 firstLineIndentChars: 0
} }
@@ -525,6 +567,7 @@ function applyHeadings(
setParagraphSpacing(pPr, { setParagraphSpacing(pPr, {
beforePt: style.headings.spacingBeforePt, beforePt: style.headings.spacingBeforePt,
afterPt: style.headings.spacingAfterPt, afterPt: style.headings.spacingAfterPt,
fontSizePt: sizePt,
lineSpacing: 1.25 lineSpacing: 1.25
}); });
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) { if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) {
@@ -569,6 +612,7 @@ function applyHeadings(
setParagraphSpacing(pPr, { setParagraphSpacing(pPr, {
beforePt: 0, beforePt: 0,
afterPt: style.headings.spacingAfterPt, afterPt: style.headings.spacingAfterPt,
fontSizePt: sizePt,
lineSpacing: 1.25 lineSpacing: 1.25
}); });
removeDirectChildren(pPr, WORD_NAMESPACE, "jc"); removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
@@ -622,6 +666,7 @@ function applyCodeAndQuote(
setParagraphSpacing(codePPr, { setParagraphSpacing(codePPr, {
beforePt: 3, beforePt: 3,
afterPt: 3, afterPt: 3,
fontSizePt: style.code.sizePt,
lineSpacing: style.code.lineSpacing, lineSpacing: style.code.lineSpacing,
firstLineIndentChars: 0 firstLineIndentChars: 0
}); });
@@ -668,6 +713,7 @@ function applyCodeAndQuote(
setParagraphSpacing(quotePPr, { setParagraphSpacing(quotePPr, {
beforePt: 6, beforePt: 6,
afterPt: 6, afterPt: 6,
fontSizePt: style.body.sizePt,
lineSpacing: style.body.lineSpacing, lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0 firstLineIndentChars: 0
}); });
@@ -813,6 +859,7 @@ function applyTableAndCaption(
setParagraphSpacing(pPr, { setParagraphSpacing(pPr, {
beforePt: 3, beforePt: 3,
afterPt: 6, afterPt: 6,
fontSizePt: style.caption.sizePt,
lineSpacing: 1.25 lineSpacing: 1.25
}); });
removeDirectChildren(pPr, WORD_NAMESPACE, "jc"); removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
@@ -1002,7 +1049,8 @@ function applyTokenStyles(
WORD_NAMESPACE, WORD_NAMESPACE,
"w:pPr" "w:pPr"
), ),
documentToken documentToken,
documentToken.fontSizePt ?? fallback.body.sizePt
); );
} }
} }
@@ -1025,7 +1073,8 @@ function applyTokenStyles(
WORD_NAMESPACE, WORD_NAMESPACE,
"w:pPr" "w:pPr"
), ),
entry.style entry.style,
fallbackFontSizeForSlot(slot, fallback)
); );
} }
applyTokenRunStyle( applyTokenRunStyle(
+58
View File
@@ -14,6 +14,7 @@ import {
readSfntFontMetadata, readSfntFontMetadata,
xorDocxFont xorDocxFont
} from "./font-embedding.js"; } from "./font-embedding.js";
import { createWordFontMetadata } from "./word-font-metadata.js";
import { import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES, MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
@@ -355,6 +356,7 @@ function validateEmbeddedFonts(
for (const font of directChildren(root, WORD_NAMESPACE, "font")) { for (const font of directChildren(root, WORD_NAMESPACE, "font")) {
const family = const family =
font.getAttributeNS(WORD_NAMESPACE, "name") ?? ""; font.getAttributeNS(WORD_NAMESPACE, "name") ?? "";
let validatedMetadata = false;
for (const role of [ for (const role of [
"embedRegular", "embedRegular",
"embedBold", "embedBold",
@@ -420,6 +422,62 @@ function validateEmbeddedFonts(
`嵌入字体家族名不匹配:${family} != ${metadata.family}` `嵌入字体家族名不匹配:${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); usedRelationships.add(relationshipId);
referencedParts.add(relationship.targetPart); referencedParts.add(relationship.targetPart);
embeddedFontCount += 1; embeddedFontCount += 1;
@@ -0,0 +1,112 @@
import type { SfntFontMetadata } from "./font-embedding.js";
export type WordFontFamily =
| "auto"
| "decorative"
| "modern"
| "roman"
| "script"
| "swiss";
export interface WordFontMetadata {
panose1: string;
charset: string;
family: WordFontFamily;
pitch: "fixed" | "variable";
signature: {
usb0: string;
usb1: string;
usb2: string;
usb3: string;
csb0: string;
csb1: string;
};
}
const codePageCharsets = [
{ bit: 18, charset: "86" },
{ bit: 20, charset: "88" },
{ bit: 17, charset: "80" },
{ bit: 19, charset: "81" },
{ bit: 21, charset: "82" },
{ bit: 16, charset: "DE" },
{ bit: 6, charset: "B2" },
{ bit: 5, charset: "B1" },
{ bit: 7, charset: "BA" },
{ bit: 2, charset: "CC" },
{ bit: 3, charset: "A1" },
{ bit: 4, charset: "A2" },
{ bit: 31, charset: "02" }
] as const;
function uint32Hex(value: number) {
return value.toString(16).toUpperCase().padStart(8, "0");
}
function byteHex(value: number) {
return value.toString(16).toUpperCase().padStart(2, "0");
}
function hasCodePageBit(
ranges: SfntFontMetadata["codePageRanges"],
bit: number
) {
const range = ranges[Math.floor(bit / 32)] ?? 0;
return ((range >>> (bit % 32)) & 1) === 1;
}
function resolveCharset(metadata: SfntFontMetadata) {
return (
codePageCharsets.find(({ bit }) =>
hasCodePageBit(metadata.codePageRanges, bit)
)?.charset ?? "00"
);
}
function resolveFamily(metadata: SfntFontMetadata): WordFontFamily {
if (metadata.fixedPitch) {
return "modern";
}
const [familyType, serifStyle] = metadata.panose;
if (familyType === 3) {
return "script";
}
if (familyType === 4) {
return "decorative";
}
if (familyType !== 2) {
return "auto";
}
if (serifStyle >= 11 && serifStyle <= 13) {
return "swiss";
}
if (
(serifStyle >= 2 && serifStyle <= 10) ||
serifStyle === 14 ||
serifStyle === 15
) {
return "roman";
}
return "auto";
}
export function createWordFontMetadata(
metadata: SfntFontMetadata
): WordFontMetadata {
const [usb0, usb1, usb2, usb3] = metadata.unicodeRanges;
const [csb0, csb1] = metadata.codePageRanges;
return {
panose1: metadata.panose.map(byteHex).join(""),
charset: resolveCharset(metadata),
family: resolveFamily(metadata),
pitch: metadata.fixedPitch ? "fixed" : "variable",
signature: {
usb0: uint32Hex(usb0),
usb1: uint32Hex(usb1),
usb2: uint32Hex(usb2),
usb3: uint32Hex(usb3),
csb0: uint32Hex(csb0),
csb1: uint32Hex(csb1)
}
};
}
@@ -39,6 +39,10 @@ describe("DOCX 字体嵌入", () => {
family: "FandolFang", family: "FandolFang",
subfamily: "Regular", subfamily: "Regular",
postscriptName: "FandolFang-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, fsType: 0x0008,
permission: "editable", permission: "editable",
noSubsetting: false, noSubsetting: false,
@@ -123,6 +127,9 @@ describe("DOCX 字体嵌入", () => {
"FandolHei", "FandolHei",
"FandolKai" "FandolKai"
]); ]);
expect(
prepared.map((font) => font.metadata.weightClass)
).toEqual([400, 700, 400, 400, 400]);
expect( expect(
prepared.every((font) => font.metadata.signature === "OTTO") prepared.every((font) => font.metadata.signature === "OTTO")
).toBe(true); ).toBe(true);
@@ -97,6 +97,15 @@ describe("DOCX 字体包写入", () => {
output.entries.get("word/fontTable.xml") output.entries.get("word/fontTable.xml")
); );
expect(fontTable).toContain('w:name="FandolFang"'); expect(fontTable).toContain('w:name="FandolFang"');
expect(fontTable).toContain(
'<w:panose1 w:val="00000500000000000000"/>'
);
expect(fontTable).toContain('<w:charset w:val="86"/>');
expect(fontTable).toContain('<w:family w:val="auto"/>');
expect(fontTable).toContain('<w:pitch w:val="variable"/>');
expect(fontTable).toContain(
'<w:sig w:usb0="80000001" w:usb1="00002000" w:usb2="00000002" w:usb3="00000000" w:csb0="00060007" w:csb1="00000000"/>'
);
expect(fontTable).toContain("<w:embedRegular"); expect(fontTable).toContain("<w:embedRegular");
expect(fontTable).toContain( expect(fontTable).toContain(
`xmlns:r="${relationshipNamespace}"` `xmlns:r="${relationshipNamespace}"`
@@ -124,6 +133,22 @@ describe("DOCX 字体包写入", () => {
expect(validateGeneratedDocx(result.content)).toMatchObject({ expect(validateGeneratedDocx(result.content)).toMatchObject({
embeddedFontCount: 1 embeddedFontCount: 1
}); });
const invalidEntries = new Map(output.entries);
invalidEntries.set(
"word/fontTable.xml",
encoder.encode(
fontTable.replace(
'<w:charset w:val="86"/>',
'<w:charset w:val="00"/>'
)
)
);
expect(() =>
validateGeneratedDocx(
writeGeneratedDocxPackage(invalidEntries)
)
).toThrow("字体元数据与嵌入字形不匹配");
}); });
it("没有字体时保持原始 DOCX 字节不变", () => { it("没有字体时保持原始 DOCX 字节不变", () => {
@@ -279,6 +279,15 @@ describe("动态 reference.docx", () => {
alignment: "center", alignment: "center",
keepWithNext: true keepWithNext: true
} }
},
{
slot: "official-edition",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
lineSpacing: 1.5
}
} }
]) ])
} }
@@ -300,6 +309,13 @@ describe("动态 reference.docx", () => {
expect(stylesXml).toContain('w:val="AA0000"'); expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('<w:jc w:val="both"/>'); expect(stylesXml).toContain('<w:jc w:val="both"/>');
expect(stylesXml).not.toContain('w:val="justify"'); expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
'w:line="504" w:lineRule="exact"'
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
);
expect(stylesXml).not.toContain('w:lineRule="auto"');
expect(stylesXml).not.toContain("<w:tblW"); expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"'); expect(stylesXml).toContain('w:color="445566"');
expect(fontTableXml).toContain( expect(fontTableXml).toContain(
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { SfntFontMetadata } from "../src/font-embedding.js";
import { createWordFontMetadata } from "../src/word-font-metadata.js";
function metadata(
overrides: Partial<SfntFontMetadata> = {}
): SfntFontMetadata {
return {
family: "Test Font",
subfamily: "Regular",
weightClass: 400,
panose: [2, 2, 5, 3, 5, 4, 5, 2, 3, 4],
unicodeRanges: [
0x20000083,
0x2adf3c10,
0x00000016,
0
],
codePageRanges: [0x60060107, 0],
fixedPitch: false,
fsType: 0,
permission: "installable",
noSubsetting: false,
signature: "\u0000\u0001\u0000\u0000",
...overrides
};
}
describe("Word 字体元数据映射", () => {
it("将 CJK Serif 的 OS/2 范围映射为简体中文 Word 字体签名", () => {
expect(createWordFontMetadata(metadata())).toEqual({
panose1: "02020503050405020304",
charset: "86",
family: "roman",
pitch: "variable",
signature: {
usb0: "20000083",
usb1: "2ADF3C10",
usb2: "00000016",
usb3: "00000000",
csb0: "60060107",
csb1: "00000000"
}
});
});
it("根据 Panose 与固定字距通用推导 Word 字体族", () => {
expect(
createWordFontMetadata(
metadata({
panose: [2, 11, 5, 3, 5, 4, 5, 2, 3, 4]
})
).family
).toBe("swiss");
expect(
createWordFontMetadata(
metadata({
panose: [3, 0, 5, 3, 5, 4, 5, 2, 3, 4]
})
).family
).toBe("script");
expect(
createWordFontMetadata(
metadata({
fixedPitch: true
})
)
).toMatchObject({
family: "modern",
pitch: "fixed"
});
});
it("在没有 CJK code-page 位时选择对应 charset 或默认 ANSI", () => {
expect(
createWordFontMetadata(
metadata({ codePageRanges: [1 << 2, 0] })
).charset
).toBe("CC");
expect(
createWordFontMetadata(
metadata({ codePageRanges: [0, 0] })
).charset
).toBe("00");
});
});