release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
This commit is contained in:
@@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("门禁 内容", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("门禁 2 / 2 内容", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "门禁内容",
|
||||
expectedEditableParagraphs: [
|
||||
{
|
||||
index: 0,
|
||||
text: "门禁",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "内容",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
expect(result.issues.map((issue) => issue.code)).not.toContain(
|
||||
"CONTENT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
@@ -59,6 +59,95 @@ function expectation(
|
||||
}
|
||||
|
||||
describe("PDF 内容感知段落版式门禁", () => {
|
||||
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
|
||||
const tableLine = line("建设内容 主要能力", 220, 320, 72);
|
||||
tableLine.items = [
|
||||
{
|
||||
text: "建设内容",
|
||||
normalizedText: "建设内容",
|
||||
bounds: { x: 72, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: false,
|
||||
},
|
||||
{
|
||||
text: "主要能力",
|
||||
normalizedText: "主要能力",
|
||||
bounds: { x: 240, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const paragraphs = [
|
||||
expectation("建设内容", { index: 0, blockKind: "table-header" }),
|
||||
expectation("主要能力", { index: 1, blockKind: "table-header" }),
|
||||
];
|
||||
const results = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[tableLine]]),
|
||||
snapshot("candidate", [[tableLine]]),
|
||||
paragraphs,
|
||||
);
|
||||
|
||||
expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[0]?.bounds,
|
||||
);
|
||||
expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[1]?.bounds,
|
||||
);
|
||||
});
|
||||
|
||||
it("按字符类别估算合并文本项中的有序列表正文边界", () => {
|
||||
const listLine = line("3.系统联调", 220, 100, 100);
|
||||
listLine.items = [
|
||||
{
|
||||
text: "3. 系统联调",
|
||||
normalizedText: "3. 系统联调",
|
||||
bounds: { x: 100, y: 210, width: 100, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[listLine]]),
|
||||
snapshot("candidate", [[listLine]]),
|
||||
[expectation("系统联调", { blockKind: "list-item" })],
|
||||
);
|
||||
|
||||
expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(117.18, 1);
|
||||
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1);
|
||||
});
|
||||
|
||||
it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
snapshot("candidate", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
[expectation("甲乙")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.firstLineBaselineYPt).toBe(220);
|
||||
expect(result?.candidate.firstLineBaselineYPt).toBe(220);
|
||||
});
|
||||
|
||||
it("连续段落可以自然跨越多个 PDF 视觉行", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
[expectation("甲乙丙丁")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]);
|
||||
});
|
||||
|
||||
it("允许正文段落整体移动到下一物理页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
||||
@@ -123,6 +212,30 @@ describe("PDF 内容感知段落版式门禁", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("正文块行数不变时允许三个字符以内的流式行内重排", () => {
|
||||
const baseline = snapshot("baseline", [[
|
||||
line("甲乙丙丁", 220),
|
||||
line("戊己庚辛", 244),
|
||||
]]);
|
||||
const candidate = snapshot("candidate", [[
|
||||
line("甲乙丙丁戊己", 220),
|
||||
line("庚辛", 244),
|
||||
]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("warning");
|
||||
expect(result?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||
const baseline = snapshot("baseline", [
|
||||
|
||||
@@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => {
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
});
|
||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
});
|
||||
|
||||
it("分别量化块背景色与背景相对的前景形状", async () => {
|
||||
const coloredRaster = (background: string, foreground: string) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = foreground;
|
||||
context.fillRect(20, 10, 40, 20);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const sameShape = await comparePageRasters(
|
||||
coloredRaster("#f2f2f2", "#202020"),
|
||||
coloredRaster("#ffffff", "#202020"),
|
||||
);
|
||||
|
||||
expect(sameShape.metrics.inkIou).toBe(1);
|
||||
expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -65,6 +65,25 @@ function blockRaster(color: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = edgeColor;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(10, 14, 16, 6);
|
||||
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,
|
||||
@@ -422,6 +441,62 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("允许块内轻微换行警告伴随正文自然跨页", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)],
|
||||
[contentLine("壬癸", 8)],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[
|
||||
contentLine("甲乙丙丁戊己", 8),
|
||||
contentLine("庚辛", 12),
|
||||
contentLine("壬癸", 16),
|
||||
],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const paragraphs = [
|
||||
{
|
||||
index: 0,
|
||||
text: "甲乙丙丁戊己庚辛",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "壬癸",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
] as const;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙丁戊己庚辛壬癸",
|
||||
expectedEditableParagraphs: paragraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.basic.paragraphLayouts?.[0]).toMatchObject({
|
||||
status: "warning",
|
||||
});
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
@@ -563,6 +638,32 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = antialiasBlockRaster("#999999");
|
||||
candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics)
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
|
||||
function metrics(
|
||||
overrides: Partial<PdfRasterMetrics> = {},
|
||||
): PdfRasterMetrics {
|
||||
return {
|
||||
baselineWidthPx: 224,
|
||||
baselineHeightPx: 37,
|
||||
candidateWidthPx: 224,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 224,
|
||||
comparedHeightPx: 37,
|
||||
dimensionsMatch: true,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 14.5,
|
||||
changedPixelRatio: 0.27,
|
||||
inkIou: 0.942,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0.02,
|
||||
foregroundColorDelta: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 222,
|
||||
inkIou: 0.88,
|
||||
foregroundColorDelta: 3.5,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
false,
|
||||
"table-header",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 0.969, edgeIou: 0.983 }),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.902,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 233,
|
||||
baselineHeightPx: 36,
|
||||
candidateHeightPx: 38,
|
||||
inkIou: 0.897,
|
||||
edgeIou: 0.9996,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 272,
|
||||
candidateWidthPx: 286,
|
||||
baselineHeightPx: 43,
|
||||
candidateHeightPx: 42,
|
||||
inkIou: 0.948,
|
||||
edgeIou: 0.958,
|
||||
foregroundColorDelta: 4.2,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ foregroundColorDelta: 4.01 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ candidateWidthPx: 220 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 207,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.89,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 40,
|
||||
inkIou: 0.95,
|
||||
edgeIou: 1,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ describe("PDF 文本行聚合", () => {
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
Reference in New Issue
Block a user