feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
+28
-1
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
|
||||||
|
|
||||||
|
|||||||
Generated
+15
-1
@@ -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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngDataUri(bytes: Uint8Array): string {
|
||||||
|
return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentage(value: number): string {
|
||||||
|
return `${(value * 100).toFixed(3)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(page: PdfVisualPageComparison): string {
|
||||||
|
if (!page.metrics) {
|
||||||
|
return '<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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("<基线>");
|
||||||
|
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user