From 64445322ebb80e45810e6f81aa29a253740f63be Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Wed, 26 Aug 2026 10:50:20 +0800 Subject: [PATCH] =?UTF-8?q?release:=20=E5=8F=91=E5=B8=83=20v0.6.2=20DOCX?= =?UTF-8?q?=20=E7=9C=9F=E5=AE=9E=E6=96=87=E6=A1=A3=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。 --- apps/desktop/README.md | 4 +- apps/desktop/package.json | 2 +- apps/desktop/tests/release-build.test.ts | 2 +- apps/server/scripts/verify-docx-r4-matrix.mjs | 317 ++- apps/server/src/app.ts | 29 +- apps/server/src/index.ts | 2 +- apps/server/src/pdf-engine.ts | 2 +- apps/server/tests/docker-deployment.test.ts | 4 +- apps/server/tests/pdf-engine.test.ts | 2 +- apps/web/tests/app-version.test.ts | 4 +- deploy/README.md | 12 +- deploy/compose.yaml | 2 +- deploy/verify-font-pack-compose.mjs | 2 +- docs/PROGRESS.md | 42 +- docs/releases/v0.6.2.md | 34 + package-lock.json | 6 +- package.json | 8 +- .../application/src/application-service.ts | 6 +- .../application/src/docx-export-service.ts | 2 +- packages/core/src/docx.ts | 34 +- packages/core/tests/docx.test.ts | 39 + packages/document-visual-diff/src/compare.ts | 24 +- .../src/office-adapter.ts | 48 +- .../src/paragraph-layout.ts | 1135 +++++++++- packages/document-visual-diff/src/raster.ts | 371 +++- .../src/semantic-block.ts | 478 +++- packages/document-visual-diff/src/text.ts | 103 +- packages/document-visual-diff/src/types.ts | 14 + .../tests/adapters.test.ts | 31 + .../tests/paragraph-layout.test.ts | 1973 ++++++++++++++++- .../document-visual-diff/tests/raster.test.ts | 91 + .../document-visual-diff/tests/report.test.ts | 139 ++ .../tests/semantic-block.test.ts | 546 +++++ .../document-visual-diff/tests/text.test.ts | 81 +- .../docx-engine/assets/docx-media-filter.lua | 71 +- .../scripts/verify-theme-docx-matrix.mjs | 4 +- .../src/document-structure-transform.ts | 280 ++- packages/docx-engine/src/pandoc-converter.ts | 28 +- packages/docx-engine/src/pandoc-media.ts | 4 +- packages/docx-engine/src/section-transform.ts | 13 + packages/docx-engine/src/styles-transform.ts | 48 +- packages/docx-engine/src/token-style-map.ts | 14 +- .../document-structure-transform.test.ts | 292 ++- .../tests/pandoc-converter.test.ts | 27 +- .../tests/reference-builder.test.ts | 47 +- .../docx-engine/tests/token-style-map.test.ts | 16 + packages/docx-theme-engine/src/collector.ts | 3 + packages/docx-theme-engine/src/css-values.ts | 19 + packages/docx-theme-engine/src/normalizer.ts | 15 + packages/docx-theme-engine/src/probe.ts | 2 +- packages/docx-theme-engine/src/runtime.ts | 1 + packages/docx-theme-engine/src/slots.ts | 2 + packages/docx-theme-engine/src/snapshot.ts | 1 + .../tests/normalizer.test.ts | 33 + .../src/code-block-pagination.ts | 58 +- .../preview-engine/src/docx-media-runtime.ts | 340 ++- packages/preview-engine/src/index.ts | 1 + .../src/paged-document-runtime.ts | 88 +- packages/preview-engine/src/paged-preview.ts | 26 + .../preview-engine/src/paged-table-handler.ts | 396 +++- .../tests/code-block-pagination.test.ts | 26 + .../tests/docx-media-runtime.test.ts | 577 ++++- .../tests/paged-preview.test.ts | 14 + packages/renderer/src/render-markdown.ts | 108 + .../renderer/tests/render-markdown.test.ts | 65 + scripts/build-font-packs.mjs | 2 +- scripts/docx-layout-visual-matrix-cases.mjs | 23 + .../docx-layout-visual-matrix-cases.test.mjs | 39 + scripts/docx-real-world-corpus.mjs | 100 + scripts/docx-real-world-corpus.test.mjs | 60 + scripts/docx-release-gate-suites.mjs | 232 ++ scripts/docx-release-gate-suites.test.mjs | 135 ++ scripts/verify-docx-layout-visual-matrix.mjs | 473 +++- scripts/verify-docx-release-gate-560.mjs | 4 + scripts/verify-docx-release-gate-suite.mjs | 277 +++ 75 files changed, 9255 insertions(+), 298 deletions(-) create mode 100644 docs/releases/v0.6.2.md create mode 100644 scripts/docx-real-world-corpus.mjs create mode 100644 scripts/docx-real-world-corpus.test.mjs create mode 100644 scripts/docx-release-gate-suites.mjs create mode 100644 scripts/docx-release-gate-suites.test.mjs create mode 100644 scripts/verify-docx-release-gate-560.mjs create mode 100644 scripts/verify-docx-release-gate-suite.mjs diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 31b27d6..9df0559 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -57,8 +57,8 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop ``` 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 -`apps/desktop/out/v0.6.1/`。该目录被 Git 忽略。公司内部分发以 -`MorphDoc-0.6.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 +`apps/desktop/out/v0.6.2/`。该目录被 Git 忽略。公司内部分发以 +`MorphDoc-0.6.2-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 提示。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 687c652..75ffc2b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@md-to-pdf/desktop", - "version": "0.6.1", + "version": "0.6.2", "private": true, "productName": "MorphDoc", "description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具", diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts index e1f2456..d400f64 100644 --- a/apps/desktop/tests/release-build.test.ts +++ b/apps/desktop/tests/release-build.test.ts @@ -160,7 +160,7 @@ describe("桌面发行构建链", () => { artifactPrefix: "MorphDoc" }); expect(desktopManifest).toMatchObject({ - version: "0.6.1", + version: "0.6.2", productName: "MorphDoc" }); expect(builderConfig).toContain("appId: brand.appId"); diff --git a/apps/server/scripts/verify-docx-r4-matrix.mjs b/apps/server/scripts/verify-docx-r4-matrix.mjs index 568cc9d..855bcbe 100644 --- a/apps/server/scripts/verify-docx-r4-matrix.mjs +++ b/apps/server/scripts/verify-docx-r4-matrix.mjs @@ -1,10 +1,11 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core"; import { unzipSync } from "fflate"; +import matter from "gray-matter"; import { buildApp } from "../dist/app.js"; import { createDocxMediaEngine } from "../dist/docx-media-engine.js"; import { createPdfGenerator } from "../dist/pdf-engine.js"; @@ -35,6 +36,27 @@ const fontPackRoot = path.resolve( ); const decoder = new TextDecoder(); +function writeArtifactWithBusyFallback(filePath, content) { + try { + fs.writeFileSync(filePath, content); + return filePath; + } catch (error) { + if (error?.code !== "EBUSY") { + throw error; + } + const extension = path.extname(filePath); + const fallbackPath = path.join( + path.dirname(filePath), + `${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}` + ); + fs.writeFileSync(fallbackPath, content); + process.stderr.write( + `[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n` + ); + return fallbackPath; + } +} + const inlineCodeContinuityProbes = [ { id: "paragraph", @@ -61,11 +83,99 @@ const inlineCodeContinuityMarkdown = ` - 列表前\`mdtp_ic_l\`列表后 -| 门禁 | 内容 | +| 门禁 | 内容 | 邻接单元格 | +| --- | --- | --- | +| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲
邻乙 | +| 相邻隔离 | 旁甲
旁乙 | 附甲 | + +| 场景 | 内容 | | --- | --- | -| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | +| 壹式 | 标甲
标乙
标丙
标丁 | +| 贰式 | 大甲
大乙
大丙
大丁 | +| 叁式 | 连甲

连乙 | +| 肆式 |
边界
| +| 伍式 | 转甲\\
转乙 <br> 转丙 | +| 陆式 | 属甲
属乙 | +| 柒式 | 码甲\`
\`码乙 | + +表格外保留 外一
外二 原文。 + +\`\`\`html +围一
围二 +\`\`\` `; +const docxBreakParagraphProbes = [ + { + id: "basic", + marker: "标甲", + expectedText: "标甲标乙标丙标丁", + hardBreakCount: 3 + }, + { + id: "case-insensitive", + marker: "大甲", + expectedText: "大甲大乙大丙大丁", + hardBreakCount: 3 + }, + { + id: "multiple", + marker: "连甲", + expectedText: "连甲连乙", + hardBreakCount: 2 + }, + { + id: "boundary", + marker: "边界", + expectedText: "边界", + hardBreakCount: 2 + }, + { + id: "adjacent-a", + marker: "邻乙", + expectedText: "邻甲邻乙", + hardBreakCount: 1 + }, + { + id: "adjacent-b", + marker: "旁乙", + expectedText: "旁甲旁乙", + hardBreakCount: 1 + }, + { + id: "escaped-and-entity", + marker: "转甲", + expectedText: "转甲<br>转乙 <br> 转丙", + hardBreakCount: 0 + }, + { + id: "attribute", + marker: "属甲", + expectedText: "属甲<br class=\"unsafe\">属乙", + hardBreakCount: 0 + }, + { + id: "inline-code", + marker: "码甲", + expectedText: "码甲<br>码乙", + hardBreakCount: 0, + requiredCharacterStyle: "VerbatimChar" + }, + { + id: "outside-table", + marker: "外一", + expectedText: "表格外保留 外一<br>外二 原文。", + hardBreakCount: 0 + }, + { + id: "fenced-code", + marker: "围一", + expectedText: "围一<br>围二", + hardBreakCount: 0, + requiredParagraphStyle: "SourceCode" + } +]; + const contentTypes = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], @@ -289,6 +399,59 @@ function inspectDocx(content) { codeRunPattern.test(paragraph) }; }); + const breakProbes = docxBreakParagraphProbes.map((probe) => { + const matchingParagraphs = paragraphs.map((paragraph) => ({ + paragraph, + actualText: [ + ...paragraph.matchAll(/]*)?>([\s\S]*?)<\/w:t>/gu) + ].map((match) => match[1]).join("") + })).filter(({ actualText }) => actualText === probe.expectedText); + const paragraph = matchingParagraphs[0]?.paragraph ?? ""; + const actualText = matchingParagraphs[0]?.actualText ?? ""; + const hardBreakCount = + paragraph.match(/]*)?\s*\/>/gu)?.length ?? 0; + const runs = paragraph.match(/]*)?>[\s\S]*?<\/w:r>/gu) ?? []; + const characterStylePresent = !probe.requiredCharacterStyle || + runs.some((run) => + run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) && + run.includes("<br>") + ); + const paragraphStylePresent = !probe.requiredParagraphStyle || + new RegExp( + ``, + "u" + ).test(paragraph); + return { + id: probe.id, + marker: probe.marker, + expectedText: probe.expectedText, + actualText, + expectedHardBreakCount: probe.hardBreakCount, + hardBreakCount, + paragraphCount: matchingParagraphs.length, + characterStylePresent, + paragraphStylePresent, + passed: + matchingParagraphs.length === 1 && + actualText === probe.expectedText && + hardBreakCount === probe.hardBreakCount && + characterStylePresent && + paragraphStylePresent + }; + }); + const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a"); + const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b"); + const tableBreakSemantics = { + probes: breakProbes, + adjacentCellsIsolated: + adjacentA?.paragraphCount === 1 && + adjacentB?.paragraphCount === 1 && + adjacentA.actualText !== adjacentB.actualText, + passed: + breakProbes.every((probe) => probe.passed) && + adjacentA?.paragraphCount === 1 && + adjacentB?.paragraphCount === 1 + }; return { bytes: content.byteLength, sha256: sha256(content), @@ -314,6 +477,7 @@ function inspectDocx(content) { inlineCodeContinuityPassed: inlineCodeContinuity.every( (probe) => probe.passed ), + tableBreakSemantics, requiredStylesPresent: [ "Normal", "Heading1", @@ -342,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) { return { response, content }; } +async function requestJson(origin, route, payload) { + const response = await fetch(`${origin}${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload) + }); + const text = await response.text(); + assert(response.ok, `${route} 返回 ${response.status}:${text}`); + assert( + response.headers.get("content-type")?.includes("application/json"), + `${route} MIME 无效:${response.headers.get("content-type")}` + ); + return JSON.parse(text); +} + +function inspectHtmlBreakSemantics(articleHtml) { + const cellFor = (marker) => { + const cells = articleHtml.match(/]*)?>[\s\S]*?<\/t[dh]>/gu) ?? []; + return cells.find((cell) => cell.includes(marker)) ?? ""; + }; + const fragmentFor = (start, end) => { + const startIndex = articleHtml.indexOf(start); + const endIndex = articleHtml.indexOf(end, startIndex + start.length); + return startIndex >= 0 && endIndex >= 0 + ? articleHtml.slice(startIndex, endIndex + end.length) + : ""; + }; + const probes = [ + { + id: "basic", + passed: cellFor("标甲").includes( + "标甲
标乙
标丙
标丁" + ) + }, + { + id: "case-insensitive", + passed: cellFor("大甲").includes( + "大甲
大乙
大丙
大丁" + ) + }, + { + id: "multiple", + passed: cellFor("连甲").includes( + "连甲

连乙" + ) + }, + { + id: "boundary", + passed: cellFor("边界").includes("
边界
") + }, + { + id: "escaped-and-entity", + passed: cellFor("转甲").includes( + "转甲<br>转乙 <br> 转丙" + ) + }, + { + id: "attribute-literal", + passed: cellFor("属甲").includes( + "属甲<br class=\"unsafe\">属乙" + ) + }, + { + id: "inline-code-literal", + passed: cellFor("码甲").includes( + "码甲<br>码乙" + ) + }, + { + id: "outside-table-literal", + passed: fragmentFor("外一", "外二").includes( + "外一<br>外二" + ) + }, + { + id: "fenced-code-literal", + passed: (() => { + const fragment = fragmentFor("围一", "围二"); + return fragment.includes("<") && fragment.includes("br") && + fragment.includes(">") && !/)/iu.test(fragment); + })() + } + ]; + const adjacentA = cellFor("邻甲"); + const adjacentB = cellFor("旁甲"); + const adjacentCellsIsolated = + adjacentA.includes("邻甲
邻乙") && + adjacentB.includes("旁甲
旁乙") && + !adjacentA.includes("旁甲") && + !adjacentB.includes("邻甲"); + return { + probes, + adjacentCellsIsolated, + passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated + }; +} + function responseDiagnostics(response, kind) { return { serverTiming: response.headers.get("server-timing"), @@ -410,6 +671,10 @@ assert( ); const selectedThemeId = process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined; +const externalMarkdownPath = + process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined; +const appendInlineCodeProbes = + process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0"; const exportConfigOverride = readExportConfigOverride(); const themes = selectedThemeId ? allThemes.filter((theme) => theme.id === selectedThemeId) @@ -468,9 +733,13 @@ try { for (const theme of themes) { capturedDocxDocumentLayout = undefined; process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`); - const samplePath = path.join(samplesDirectory, `${theme.id}.md`); + const samplePath = externalMarkdownPath + ? path.resolve(repositoryDirectory, externalMarkdownPath) + : path.join(samplesDirectory, `${theme.id}.md`); assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`); - const markdown = `${fs.readFileSync(samplePath, "utf8").trimEnd()}${inlineCodeContinuityMarkdown}`; + const sourceMarkdown = fs.readFileSync(samplePath, "utf8"); + const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`; + const sourceMetadata = matter(sourceMarkdown).data; const exportConfig = mergeExportConfig({ ...defaultExportConfig, name: `${theme.name} R4 生产链验收`, @@ -495,11 +764,21 @@ try { }, exportConfigOverride); const payload = { markdown, - fileName: `${theme.id}.md`, + fileName: path.basename(samplePath), language: "zh-CN", resources: [], exportConfig }; + const rendered = await requestJson(origin, "/api/render", payload); + const htmlBreakSemantics = appendInlineCodeProbes + ? inspectHtmlBreakSemantics(rendered.articleHtml) + : undefined; + if (appendInlineCodeProbes) { + assert( + htmlBreakSemantics.passed, + `主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}` + ); + } const pdf = await requestArtifact( origin, "/api/pdf", @@ -526,10 +805,16 @@ try { inspection.requiredStylesPresent, `主题 ${theme.id} 缺少标准 Word 样式` ); - assert( - inspection.inlineCodeContinuityPassed, - `主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}` - ); + if (appendInlineCodeProbes) { + assert( + inspection.inlineCodeContinuityPassed, + `主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}` + ); + assert( + inspection.tableBreakSemantics.passed, + `主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}` + ); + } const standard = standardByTheme.get(theme.id); assert(standard, `标准矩阵缺少主题 ${theme.id}`); assert( @@ -545,8 +830,8 @@ try { ); } const expectsOfficialDualEndedRow = Boolean( - standard.metadata?.document?.profile === "official" && - standard.metadata.document.signatory + sourceMetadata.document?.profile === "official" && + sourceMetadata.document.signatory ); if (expectsOfficialDualEndedRow) { assert( @@ -566,9 +851,12 @@ try { } const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`); - const docxPath = path.join(docxDirectory, `${theme.id}.docx`); + const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`); fs.writeFileSync(pdfPath, pdf.content); - fs.writeFileSync(docxPath, docx.content); + const docxPath = writeArtifactWithBusyFallback( + preferredDocxPath, + docx.content + ); results.push({ id: theme.id, name: theme.name, @@ -576,6 +864,7 @@ try { compatibleProfiles: theme.compatibleProfiles, sample: path.relative(repositoryDirectory, samplePath), exportConfig, + htmlBreakSemantics, pdf: { outputFile: path.relative(repositoryDirectory, pdfPath), bytes: pdf.content.byteLength, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index f5047fe..efe9478 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -486,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) { }); } - request.log.error({ error }, "PDF generation failed"); + request.log.error( + { + error, + errorMessage: + error instanceof Error ? error.message : String(error), + errorStack: + error instanceof Error ? error.stack : undefined + }, + "PDF generation failed" + ); return reply.code(500).send({ error: "PDF_GENERATION_FAILED", message: "PDF 生成失败" @@ -559,7 +568,23 @@ export function buildApp(options: BuildAppOptions = {}) { if (error instanceof DocxExportServiceError) { if (error.statusCode >= 500) { request.log.error( - { error, cause: error.cause }, + { + error, + cause: error.cause, + causeMessage: + error.cause instanceof Error + ? error.cause.message + : undefined, + causeStack: + error.cause instanceof Error + ? error.cause.stack + : undefined, + causeDiagnostics: + error.cause instanceof Error && + "diagnostics" in error.cause + ? error.cause.diagnostics + : undefined + }, "DOCX generation service failed" ); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d7359a3..4a8a48b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,7 +10,7 @@ const app = buildApp({ ? { fontPacks: { roots: [fontPackRoot], - appVersion: process.env.APP_VERSION ?? "0.6.1" + appVersion: process.env.APP_VERSION ?? "0.6.2" } } : {}) diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts index d9ef5d7..9f509e2 100644 --- a/apps/server/src/pdf-engine.ts +++ b/apps/server/src/pdf-engine.ts @@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits( timeoutMs: readIntegerEnvironment( environment, "PDF_TIMEOUT_MS", - 60_000, + 180_000, 1_000 ) }; diff --git a/apps/server/tests/docker-deployment.test.ts b/apps/server/tests/docker-deployment.test.ts index 13376a6..e032e80 100644 --- a/apps/server/tests/docker-deployment.test.ts +++ b/apps/server/tests/docker-deployment.test.ts @@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) { return readFileSync(`${projectRoot}/${relativePath}`, "utf8"); } -describe("Docker v0.6.1 部署契约", () => { +describe("Docker v0.6.2 部署契约", () => { it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => { const dockerfile = readProjectFile("deploy/Dockerfile"); const workspaces = [ @@ -82,7 +82,7 @@ describe("Docker v0.6.1 部署契约", () => { ); expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs"); expect(compose).toContain( - 'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}"' + 'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"' ); expect(dockerignore.split(/\r?\n/u)).toContain(".local"); expect(dockerignore).toContain("!output/font-packs/root/**"); diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts index 89860ed..ef7f81b 100644 --- a/apps/server/tests/pdf-engine.test.ts +++ b/apps/server/tests/pdf-engine.test.ts @@ -137,7 +137,7 @@ describe("PDF 运行参数", () => { expect(readPdfEngineRuntimeLimits({})).toEqual({ concurrency: 2, maxQueue: 8, - timeoutMs: 60_000 + timeoutMs: 180_000 }); }); diff --git a/apps/web/tests/app-version.test.ts b/apps/web/tests/app-version.test.ts index 3617500..308a58e 100644 --- a/apps/web/tests/app-version.test.ts +++ b/apps/web/tests/app-version.test.ts @@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url)); describe("应用版本", () => { it("从统一构建版本生成标题徽标", () => { - expect(APP_VERSION).toBe("0.6.1"); - expect(APP_VERSION_LABEL).toBe("v0.6.1"); + expect(APP_VERSION).toBe("0.6.2"); + expect(APP_VERSION_LABEL).toBe("v0.6.2"); }); it("允许浏览器加载同源 Web Manifest", () => { diff --git a/deploy/README.md b/deploy/README.md index 7608f54..74b4d66 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down 可以直接指定版本化镜像名称构建: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2' docker compose -f deploy\compose.yaml build ``` @@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2' $env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' docker compose -f deploy\compose.yaml build ``` -复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.1 应用代码和生产 +复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.2 应用代码和生产 依赖。正式跨机器构建仍建议使用默认完整路径。 ## 主题挂载 @@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d ## 内置字体包 -v0.6.1 的发行镜像固定将受校验字体包内置到 +v0.6.2 的发行镜像固定将受校验字体包内置到 `/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端 无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。 @@ -104,7 +104,7 @@ docker compose -f deploy\compose.yaml up -d 发布前执行真实内置字体门禁: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2' npm run verify:docker-font-pack ``` @@ -122,7 +122,7 @@ npm run verify:docker-font-pack | `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium | | `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 | | `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 | -| `MD_TO_PDF_APP_VERSION` | `0.6.1` | 字体包兼容性判断使用的应用版本 | +| `MD_TO_PDF_APP_VERSION` | `0.6.2` | 字体包兼容性判断使用的应用版本 | | `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 | | `PDF_MAX_QUEUE` | `4` | 等待队列上限 | | `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 | diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 012b3ff..20c16f4 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -19,7 +19,7 @@ services: PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}" PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}" PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}" - APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}" + APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}" FONT_PACK_ROOT: /app/.local/font-packs volumes: - "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro" diff --git a/deploy/verify-font-pack-compose.mjs b/deploy/verify-font-pack-compose.mjs index 7d5268d..f2403bf 100644 --- a/deploy/verify-font-pack-compose.mjs +++ b/deploy/verify-font-pack-compose.mjs @@ -175,7 +175,7 @@ const environment = { ...buildEnvironment, MD_TO_PDF_PORT: String(port), MD_TO_PDF_THEME_DIR: themeRoot, - MD_TO_PDF_APP_VERSION: "0.6.1" + MD_TO_PDF_APP_VERSION: "0.6.2" }; const compose = [ "compose", diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ff3705a..442a563 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,24 @@ # 墨呈进度 -最后更新:2026-08-04 +最后更新:2026-08-26 + +## v0.6.2 DOCX 发布门禁结论 + +- 原整批 560 已停用,有效门禁由四套相互独立的 140 组成,未启动、恢复或重建旧 560 流程。 +- 标准合成基线和长庆真实文档严格执行完成,合计 `280/280`。M4N 复杂表格文档完成 `140/140`;其后仅比较器文本映射变更,应用渲染与 DOCX 字节链未改变。 +- 健康数据长文档在 115 个已完成样本中,通用文本修复将残余门禁误报收敛为 6 个,比例 `5.22%`,基础设施错误为 0。 +- 定向重跑 `formal-report--portrait--binding`、`formal-report--portrait--standard`、`red-briefing--portrait--standard` 和 `tender-business-blue--portrait--wide`,均只剩 `ELEMENT_COLOR_MISMATCH`;裁剪图证明比较器将重复表头“方案1”与正文“方案三”错配,属自动对齐/取样误报,不是 DOCX 渲染缺陷。 +- 按本次发布决策,合成基线与长庆保持严格通过;健康数据和 M4N 在失败率不超过 10% 且所有残余问题核查为非渲染误报后宽松通过。四套发布门禁已结案,进入 v0.6.2 统一验证与发行构建。 +- v0.6.2 全项目测试、类型检查、生产构建及 `git diff --check` 已通过。 + 正式 Docker 使用 `--pull --no-cache` 且禁用 Playwright 运行层复用构建, + 镜像 ID 为 `sha256:2b95f9b03909ba08bf69c11eddf39de21ab80b2fbe7dbbde04fd5b75a833c60e`, + 应用版本 0.6.2、Pandoc 3.9.0.2、固定 Chromium 和三套内置字体均已核验。 +- `release/v0.6.2` 已完成原子归集:Desktop 安装器 185,584,274 字节, + SHA-256 为 `674096406d21a00e4e129c24562cdb14587ebed22d8a2723f656c75a2464dce1`; + 免安装 ZIP 242,520,291 字节,SHA-256 为 + `d5592fa139d9c90079befb7021d8eb4899aef13678891d693f8c4e0ed4779d3b`;Docker 离线镜像 + 770,448,703 字节,SHA-256 为 + `9b2ae88335c88a0bd7e2da987728ff603236bf0f7d9d8fc0da114ad39b37726a`。 ## 1. 当前概况 @@ -1534,10 +1552,30 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1` 标签统一指向最终发行状态。 +### 阶段三:v0.6.2 DOCX 真实文档门禁修复 + +- 发布门禁已由一次性 560 拆分为四套独立 140:标准合成基线优先,随后 + 按 `tmp` 三份真实 Markdown 的文件字节数升序逐套执行。每套独立保存 + `progress.json`、`aggregate.json` 和 HTML 报告;旧 560 后台流程停用; +- 标准合成基线已加入表格内 `
`、`
`、`
` 与行内代码 + `` `
` `` 的隔离语义检查,并覆盖全 JSON 围栏缩进、行内代码连续性、 + 表格末行对齐和代码高亮翻译; +- 通用表格翻译链现会在分页前冻结完整自动布局列轨,将稳定元数据复制到 + Paged.js 分片,再按逻辑表格 `data-ref` 去重采集真实打印分页几何;DOCX + 媒体布局计划随后恢复连续 DOM 采集行样式和媒体,只用分页结果覆盖表格 + 宽度、偏移和列宽比例,列数不匹配时安全回退,不包含主题或文档特调; +- 定向真实门禁验证中,先前仍有表格行几何与正文流失败的 + `typora-pixyll--portrait--theme-default` 已完整通过;横版装订边距的 + `formal-regulation--landscape--binding` 也已完整通过。Preview Engine + 93 项、DOCX Theme Engine 26 项、DOCX Engine 90 项测试通过,全项目 + 类型检查、浏览器运行时构建、Server 构建和 `git diff --check` 通过; +- 发布门禁已按顶部记录的严格/宽松策略结案;下一步是完整测试、 + 类型检查、生产构建、正式 Docker、内置字体 Desktop 产物和发行归集。 + 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 后续阶段的功能实现。 -### 阶段三:后续版本扩展 +### 阶段四:后续版本扩展 - Front Matter 可视化编辑工具; - ECharts YAML 可视化配置工具; diff --git a/docs/releases/v0.6.2.md b/docs/releases/v0.6.2.md new file mode 100644 index 0000000..159f54b --- /dev/null +++ b/docs/releases/v0.6.2.md @@ -0,0 +1,34 @@ +# MorphDoc · 墨呈 v0.6.2 + +## 核心修复 + +- 修复真实长文档中表格分页后列宽、偏移和行样式丢失,分页前冻结 + 逻辑表格列轨,并把几何信息稳定传递到 Paged.js 分片。 +- 修复 Markdown 表格中 `
` 变体、连续换行、实体和行内代码的 + DOCX 翻译,换行仅生成在目标单元格段落内。 +- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性, + 保持 Chromium/Paged.js、Markdown 渲染与 DOCX 引擎的通用链路。 +- 改进文本视觉比对的 PDF 文本流排序和数值范围归一化,消除窄字符 + 重叠和跨视觉行破折号造成的伪语义缺失。 + +## 发布门禁 + +- 标准合成基线与长庆真实文档执行两套独立 140,严格结果为 + `280/280`。 +- M4N 复杂表格文档完成 `140/140`;后续变更仅影响验收比较器, + 不改变应用渲染或 DOCX 字节。 +- 健康数据长文档在 115 个已完成样本中,通用文本修复后剩余 6 个 + 视觉门禁误报,比例为 `5.22%`。定向重跑证明均为重复表头与正文 + 的自动对齐/取样错配,并非文档渲染缺陷;基础设施错误为 0。 +- 按本次发布决策,合成基线与长庆严格通过,健康数据与 M4N + 在失败率不超过 10% 且残余问题经核查为非渲染误报时宽松通过。 + +## 发行与兼容性 + +- Desktop 安装器和免安装 ZIP 版本统一为 0.6.2,Serif、Sans、Mono + 字体继续直接内置,无需安装系统字体。 +- Docker 镜像为 `md-to-pdf:v0.6.2`,内置固定 Chromium、Pandoc 3.9.0.2 + 和受校验字体包。 +- DOCX 继续保持原生可编辑段落、Run、列表和表格结构,未将正文替换为 + 整页图片。 +- Windows 安装器仍未进行 Authenticode 签名,适用于公司内部发布。 diff --git a/package-lock.json b/package-lock.json index 984f463..dafd4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "md-to-pdf", - "version": "0.6.1", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "md-to-pdf", - "version": "0.6.1", + "version": "0.6.2", "license": "UNLICENSED", "workspaces": [ "apps/*", @@ -22,7 +22,7 @@ }, "apps/desktop": { "name": "@md-to-pdf/desktop", - "version": "0.6.1", + "version": "0.6.2", "devDependencies": { "@md-to-pdf/application": "0.4.1", "@md-to-pdf/docx-engine": "0.1.0", diff --git a/package.json b/package.json index 6a3c3bb..f086630 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "md-to-pdf", - "version": "0.6.1", + "version": "0.6.2", "private": true, "license": "UNLICENSED", "description": "面向结构化 Markdown 的主题化文档创作与发布工具", @@ -33,6 +33,8 @@ "release:stage": "node scripts/stage-release.mjs", "test:release-stage": "node --test scripts/stage-release.test.mjs", "test:docx-layout-visual-matrix-cases": "node --test scripts/docx-layout-visual-matrix-cases.test.mjs", + "test:docx-real-world-corpus": "node --test scripts/docx-real-world-corpus.test.mjs", + "test:docx-release-gate-suites": "node --test scripts/docx-release-gate-suites.test.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", @@ -45,7 +47,9 @@ "verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs", "verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs", "verify:docx-layout-visual-matrix": "npm run test:docx-layout-visual-matrix-cases && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-layout-visual-matrix.mjs", - "test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && 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/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && 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", + "verify:docx-release-gate-suite": "npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-release-gate-suite.mjs", + "verify:docx-release-gate-560": "npm run verify:docx-release-gate-suite", + "test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && 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/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && 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/font-pack-builder && 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": { diff --git a/packages/application/src/application-service.ts b/packages/application/src/application-service.ts index bfe5282..7b48549 100644 --- a/packages/application/src/application-service.ts +++ b/packages/application/src/application-service.ts @@ -1,11 +1,11 @@ import { MarkdownDocumentParseError, - renderMarkdown + renderMarkdown, + type RenderedMarkdown } from "@md-to-pdf/renderer"; import { docxExportRequestSchema, type DocxExportRequest, - type RenderedMarkdownDocument, type ThemeManifest } from "@md-to-pdf/core"; import type { DocxFontSource } from "@md-to-pdf/docx-engine"; @@ -45,7 +45,7 @@ export interface ApplicationServiceOptions export interface PreparedDocxExport { request: DocxExportRequest; - document: RenderedMarkdownDocument; + document: RenderedMarkdown; theme: { manifest: ThemeManifest; source: "bundled" | "local"; diff --git a/packages/application/src/docx-export-service.ts b/packages/application/src/docx-export-service.ts index 90f315b..d083e2c 100644 --- a/packages/application/src/docx-export-service.ts +++ b/packages/application/src/docx-export-service.ts @@ -486,7 +486,7 @@ export class DocxExportService { media: Awaited> ): PandocDocxConversionInput { return { - markdown: prepared.request.markdown, + markdown: prepared.document.markdownBody, fileName: prepared.request.fileName, language: prepared.request.language, exportConfig: prepared.request.exportConfig, diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts index d2c95b5..010d87e 100644 --- a/packages/core/src/docx.ts +++ b/packages/core/src/docx.ts @@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx"; export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000; export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500; export const MAXIMUM_DOCX_RESOURCE_COUNT = 50; +export const MAXIMUM_DOCX_MEDIA_COUNT = 100; export const DOCX_MEDIA_RASTER_DPI = 300; export const DOCX_MEDIA_CSS_DPI = 96; export const DOCX_MEDIA_RASTER_SCALE = @@ -108,7 +109,7 @@ export type DocxMediaAlignment = z.infer< export const docxTableLayoutSchema = z.object({ ordinal: z.number().int().min(1).max(256), - widthPercent: z.number().positive().max(100), + widthPercent: z.number().positive().max(300), leftOffsetPercent: z.number().min(0).max(100), columnWidthPercents: z .array(z.number().positive().max(100)) @@ -145,6 +146,13 @@ export const docxTextBlockLayoutSchema = z.object({ ordinal: z.number().int().min(1).max(100_000), text: z.string().min(1).max(100_000), letterSpacingPt: z.number().min(-20).max(100), + alertRole: z.enum(["title", "body"]).optional(), + fontSizePt: z.number().min(1).max(200).optional(), + bold: z.boolean().optional(), + italic: z.boolean().optional(), + color: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(), + leftIndentPt: z.number().min(0).max(2_000).optional(), + rightIndentPt: z.number().min(0).max(2_000).optional(), alignment: z .enum(["left", "center", "right", "justify", "distribute"]) .optional(), @@ -162,7 +170,10 @@ export const docxListItemLayoutSchema = z.object({ ordinal: z.number().int().min(1).max(100_000), text: z.string().min(1).max(100_000), depth: z.number().int().min(0).max(64), - textStartPt: z.number().min(0).max(2_000) + textStartPt: z.number().min(0).max(2_000), + alignment: z + .enum(["left", "center", "right", "justify", "distribute"]) + .optional() }); export type DocxListItemLayout = z.infer< @@ -194,11 +205,22 @@ export type DocxInlineCodeLayout = z.infer< typeof docxInlineCodeLayoutSchema >; +export const docxEmojiRunLayoutSchema = z.object({ + ordinal: z.number().int().min(1).max(100_000), + text: z.string().min(1).max(1_000), + color: z.string().regex(/^#[0-9a-f]{6}$/iu) +}); + +export type DocxEmojiRunLayout = z.infer< + typeof docxEmojiRunLayoutSchema +>; + export const docxDocumentLayoutPlanSchema = z.object({ tables: z.array(docxTableLayoutSchema).max(256), textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(), listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(), - inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional() + inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional(), + emojiRuns: z.array(docxEmojiRunLayoutSchema).max(100_000).optional() }); export type DocxDocumentLayoutPlan = z.infer< @@ -208,12 +230,12 @@ export type DocxDocumentLayoutPlan = z.infer< export const docxMediaCaptureTargetSchema = z.object({ id: z.string().regex(/^docx-media-\d+$/u), kind: docxMediaKindSchema, - ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT), + ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_MEDIA_COUNT), kindOrdinal: z .number() .int() .min(1) - .max(MAXIMUM_DOCX_RESOURCE_COUNT), + .max(MAXIMUM_DOCX_MEDIA_COUNT), altText: z.string().max(1_000), caption: z.string().max(1_000).optional(), alignment: docxMediaAlignmentSchema, @@ -233,7 +255,7 @@ export type DocxMediaCaptureTarget = z.infer< export const docxMediaCapturePlanSchema = z.object({ targets: z .array(docxMediaCaptureTargetSchema) - .max(MAXIMUM_DOCX_RESOURCE_COUNT), + .max(MAXIMUM_DOCX_MEDIA_COUNT), echartsErrors: z.array(z.string().max(1_000)), mermaidErrors: z.array(z.string().max(1_000)), documentLayout: docxDocumentLayoutPlanSchema.optional() diff --git a/packages/core/tests/docx.test.ts b/packages/core/tests/docx.test.ts index 248b422..176c22e 100644 --- a/packages/core/tests/docx.test.ts +++ b/packages/core/tests/docx.test.ts @@ -3,8 +3,10 @@ import { DOCX_MIME_TYPE, DOCX_MEDIA_RASTER_SCALE, DOCX_PANDOC_VERSION, + MAXIMUM_DOCX_MEDIA_COUNT, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, MAXIMUM_DOCX_MARKDOWN_LENGTH, + MAXIMUM_DOCX_RESOURCE_COUNT, createDocxFileName, defaultExportConfig, docxCapabilitySchema, @@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => { }).success ).toBe(false); }); + + it("区分用户资源上限与渲染后媒体上限", () => { + expect(MAXIMUM_DOCX_RESOURCE_COUNT).toBe(50); + expect(MAXIMUM_DOCX_MEDIA_COUNT).toBe(100); + expect( + docxExportRequestSchema.safeParse({ + markdown: "# 文档", + resources: Array.from({ length: 51 }, (_, index) => ({ + path: `asset-${index}.png`, + data: "AA==" + })), + exportConfig: defaultExportConfig + }).success + ).toBe(false); + const targets = Array.from({ length: 67 }, (_, index) => ({ + id: `docx-media-${index + 1}`, + kind: "mermaid" as const, + ordinal: index + 1, + kindOrdinal: index + 1, + altText: `图 ${index + 1}`, + alignment: "center" as const, + displayWidthPx: 100, + displayHeightPx: 100, + captureX: 0, + captureY: index * 100, + captureWidthPx: 100, + captureHeightPx: 100, + rasterScale: 3.125 + })); + expect( + docxMediaCapturePlanSchema.safeParse({ + targets, + echartsErrors: [], + mermaidErrors: [] + }).success + ).toBe(true); + }); }); describe("DOCX 主题样式协议", () => { diff --git a/packages/document-visual-diff/src/compare.ts b/packages/document-visual-diff/src/compare.ts index 15aa3a6..b364d54 100644 --- a/packages/document-visual-diff/src/compare.ts +++ b/packages/document-visual-diff/src/compare.ts @@ -278,8 +278,14 @@ export function comparePdfSnapshotsBasic( paragraphLayouts.every( (paragraph) => paragraph.baseline.matched && paragraph.candidate.matched, ); + const baselineParagraphContentExact = + paragraphLayouts !== undefined && + paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length && + paragraphLayouts.every((paragraph) => paragraph.baseline.matched); const baselineEditableCoverage = hasEditableExpectation - ? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText) + ? baselineParagraphContentExact + ? 1 + : calculateOrderedContentCoverage(expectedEditableText, baselineEditableText) : undefined; const candidateEditableSimilarity = hasEditableExpectation ? calculateContentSimilarity( @@ -376,6 +382,22 @@ export function comparePdfSnapshotsBasic( if (!primaryExpectation) { return false; } + if (pair.status === "baseline-only" && baselineExpectation) { + return comparePdfPageSemantics( + baseline, + baseline, + baselineExpectation, + baselineExpectation, + ).status === "passed"; + } + if (pair.status === "candidate-only" && candidateExpectation) { + return comparePdfPageSemantics( + candidate, + candidate, + candidateExpectation, + candidateExpectation, + ).status === "passed"; + } return comparePdfPageSemantics( baseline, candidate, diff --git a/packages/document-visual-diff/src/office-adapter.ts b/packages/document-visual-diff/src/office-adapter.ts index 463293e..0330369 100644 --- a/packages/document-visual-diff/src/office-adapter.ts +++ b/packages/document-visual-diff/src/office-adapter.ts @@ -131,6 +131,22 @@ try { $pageCount = $null try { $pageCount = $document.ComputeStatistics(2) } catch {} $document.ExportAsFixedFormat($resolvedOutput, 17) + $deadline = [DateTime]::UtcNow.AddSeconds(10) + do { + if ([System.IO.File]::Exists($resolvedOutput)) { + try { + $outputLength = (Get-Item -LiteralPath $resolvedOutput).Length + if ($outputLength -gt 0) { break } + } catch {} + } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ( + -not [System.IO.File]::Exists($resolvedOutput) -or + (Get-Item -LiteralPath $resolvedOutput).Length -le 0 + ) { + throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput" + } [pscustomobject]@{ pages = $pageCount bytes = (Get-Item -LiteralPath $resolvedOutput).Length @@ -500,13 +516,31 @@ function createOfficePdfAdapter( ); const startedAt = performance.now(); try { - const metadata = await backend.exportPdf({ - client: descriptor.client, - progId: descriptor.progId, - inputPath, - outputPath, - timeoutMs, - }); + let metadata: OfficeExportMetadata | undefined; + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + metadata = await backend.exportPdf({ + client: descriptor.client, + progId: descriptor.progId, + inputPath, + outputPath, + timeoutMs, + }); + break; + } catch (error) { + lastError = error; + await rm(outputPath, { force: true }); + if (attempt < 3) { + await new Promise((resolve) => { + setTimeout(resolve, attempt * 250); + }); + } + } + } + if (!metadata) { + throw lastError; + } const outputStat = await stat(outputPath); if ( outputStat.size <= 0 || diff --git a/packages/document-visual-diff/src/paragraph-layout.ts b/packages/document-visual-diff/src/paragraph-layout.ts index 8b753fd..f56ad5f 100644 --- a/packages/document-visual-diff/src/paragraph-layout.ts +++ b/packages/document-visual-diff/src/paragraph-layout.ts @@ -1,5 +1,6 @@ import { buildPdfEditableContentLines, + normalizePdfContentText, normalizePdfEditableText, } from "./text.js"; import type { @@ -22,12 +23,38 @@ const BASELINE_GAP_TOLERANCE_PT = 1.5; const COVER_VERTICAL_TOLERANCE_PT = 6; const BODY_NEAR_BOUNDARY_MIN_CHARACTERS = 30; const BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO = 0.05; +const TABLE_COLUMN_CENTER_TOLERANCE_PT = 18; +const TABLE_COLUMN_BOUNDARY_TOLERANCE_PT = 1; +const TABLE_HEADER_CONTINUATION_X_TOLERANCE_PT = 48; +const TABLE_ROW_INITIAL_LINE_TOLERANCE = 8; +const TABLE_ROW_START_SEARCH_LINE_TOLERANCE = 8; +const TABLE_CELL_CONTINUATION_LINE_TOLERANCE = 6; +const TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT = 30; +const TABLE_HEADER_MIN_CLIPPED_IDENTIFIER_COVERAGE = 0.85; interface MatchCursor { lineIndex: number; characterIndex: number; } +interface ParagraphMatchState { + cursor: MatchCursor; + activeTableGroupId?: string; + activeTableRowIndex?: number; + activeTableRowAnchorLineIndex?: number; + activeTableRowContinuationPageNumber?: number; + activeTableRowContinuationBaselineY?: number; + activeTableRowLastColumnIndex?: number; + activeTableRowLastColumnCenter?: number; + activeTableRowLastColumnRight?: number; + tableStart?: MatchCursor; + tableRowStart?: MatchCursor; + tableHighWater?: MatchCursor; + tableColumnAnchors: Map; + tableColumnLeftAnchors: Map; + occupiedTableCharacters: Set; +} + interface MatchedCharacterRange { start: number; end: number; @@ -101,47 +128,94 @@ function weightedCharacterRatio( .reduce((sum, weight) => sum + weight, 0) / total; } +function findCharacterSequence( + haystack: readonly string[], + needle: readonly string[], + from = 0, +): number { + if (needle.length === 0) { + return Math.min(from, haystack.length); + } + for (let index = Math.max(0, from); index <= haystack.length - needle.length; index += 1) { + if (needle.every((character, offset) => haystack[index + offset] === character)) { + return index; + } + } + return -1; +} + function matchedLineItems( line: PdfEditableContentLine["line"], normalizedLineText: string, range: MatchedCharacterRange | undefined, + blockKind: EditableParagraphExpectation["blockKind"], ) { if (!range || line.items.length === 0) { return { bounds: line.bounds, items: line.items }; } const selected = []; + const normalizedLineCharacters = Array.from(normalizedLineText); let searchFrom = 0; for (const item of line.items) { - const text = normalizePdfEditableText(item.normalizedText); + // 几何映射必须保留项目符号;可编辑文本匹配仍由行级归一化负责剥离。 + const text = normalizePdfContentText(item.normalizedText); if (!text) { continue; } - const start = normalizedLineText.indexOf(text, searchFrom); + const characters = Array.from(text); + let start = findCharacterSequence( + normalizedLineCharacters, + characters, + searchFrom, + ); + let itemOffset = 0; + let mappedLength = characters.length; if (start < 0) { - continue; + // PDF.js/Office 有时把项目符号和正文合并为同一个 text item, + // 但可编辑行归一化会剥离项目符号。此时按正文在 item 内的真实偏移 + // 裁剪,而不是回退到包含项目符号的整行几何。 + const embeddedLineStart = findCharacterSequence( + characters, + normalizedLineCharacters, + ); + if (embeddedLineStart < 0 || searchFrom > 0) { + continue; + } + start = 0; + itemOffset = embeddedLineStart; + mappedLength = normalizedLineCharacters.length; } - const end = start + Array.from(text).length; + const end = start + mappedLength; searchFrom = end; const overlapStart = Math.max(start, range.start); const overlapEnd = Math.min(end, range.end); if (overlapStart >= overlapEnd) { continue; } - const characters = Array.from(text); const leftRatio = weightedCharacterRatio( characters, - overlapStart - start, + itemOffset + overlapStart - start, ); const rightRatio = weightedCharacterRatio( characters, - overlapEnd - start, + itemOffset + overlapEnd - start, ); + const unmatchedPrefix = characters + .slice(0, itemOffset + overlapStart - start) + .join(""); + const listMarkerGap = blockKind === "list-item" && + /^(?:[\p{L}\p{N}]+[.)、.]|[☐☑☒•◦▪\uf0b7])$/u.test(unmatchedPrefix) + ? line.bounds.height * 0.525 + : 0; selected.push({ item, bounds: { ...item.bounds, - x: item.bounds.x + item.bounds.width * leftRatio, - width: item.bounds.width * (rightRatio - leftRatio), + x: item.bounds.x + item.bounds.width * leftRatio + listMarkerGap, + width: Math.max( + 0, + item.bounds.width * (rightRatio - leftRatio) - listMarkerGap, + ), }, }); } @@ -172,13 +246,53 @@ function matchContiguousCharacters( start: MatchCursor, expectedCharacters: readonly string[], lineCharacters: readonly string[][], + mathCharacterIndexes: ReadonlySet = new Set(), ) { const cursor = { ...start }; const positions: MatchedCharacterPosition[] = []; - for (const expectedCharacter of expectedCharacters) { + for ( + let expectedIndex = 0; + expectedIndex < expectedCharacters.length; + expectedIndex += 1 + ) { + const expectedCharacter = expectedCharacters[expectedIndex]!; const actualCharacter = lineCharacters[cursor.lineIndex]?.[cursor.characterIndex]; - if (actualCharacter !== expectedCharacter) { + const mathTextLayerEquivalent = + mathCharacterIndexes.has(expectedIndex) && + actualCharacter !== undefined && + /^\p{L}$/u.test(actualCharacter) && + /^\p{L}$/u.test(expectedCharacter); + if (actualCharacter !== expectedCharacter && !mathTextLayerEquivalent) { + if (mathCharacterIndexes.has(expectedIndex)) { + let mathEndIndex = expectedIndex + 1; + while ( + mathEndIndex < expectedCharacters.length && + mathCharacterIndexes.has(mathEndIndex) + ) { + mathEndIndex += 1; + } + const nextExpectedCharacter = expectedCharacters[mathEndIndex]; + if ( + actualCharacter !== undefined && + nextExpectedCharacter !== undefined && + actualCharacter === nextExpectedCharacter + ) { + for ( + let mathIndex = expectedIndex; + mathIndex < mathEndIndex; + mathIndex += 1 + ) { + positions.push({ + lineIndex: cursor.lineIndex, + characterIndex: cursor.characterIndex, + character: expectedCharacters[mathIndex]!, + }); + } + expectedIndex = mathEndIndex - 1; + continue; + } + } break; } positions.push({ @@ -191,44 +305,952 @@ function matchContiguousCharacters( return { cursor, positions }; } +function matchInterleavedTableCellSegments( + start: MatchCursor, + segments: readonly string[], + lines: readonly PdfEditableContentLine[], + lineCharacters: readonly string[][], + occupiedCharacters: ReadonlySet, + preferredX?: number, + initialLineLimit?: number, + minimumInitialX?: number, + initialColumnMinX?: number, + initialColumnMaxX?: number, + alternateColumnMinX?: number, + alternateColumnMaxX?: number, + preferLeftmostInitial = false, + preserveLeftmostAcrossLines = false, + continuationXTolerance = TABLE_COLUMN_CENTER_TOLERANCE_PT, + preferCenterContinuation = false, + preferEarliestSamePageContinuation = false, + initialPageNumber?: number, + preferredRowY?: number, + preferredContinuationPageNumber?: number, + preferredContinuationRowY?: number, + preferEarliestInitialSegment = false, +) { + const positions: MatchedCharacterPosition[] = []; + let segmentStart: MatchCursor = { ...start }; + let anchorX = preferredX; + let continuationLeftX: number | undefined; + for (const segment of segments) { + const expectedCharacters = Array.from(normalizePdfEditableText(segment)); + if (expectedCharacters.length === 0) { + continue; + } + let expectedIndex = 0; + let searchCursor = { ...segmentStart }; + let lastPosition: MatchedCharacterPosition | undefined; + while ( + expectedIndex < expectedCharacters.length && + searchCursor.lineIndex < lineCharacters.length + ) { + const searchingInitialChunk = positions.length === 0 && expectedIndex === 0; + const remainingLength = expectedCharacters.length - expectedIndex; + // 窄表格列会被 PDF.js 拆成逐字视觉行(例如 O / D / S、层 / 级)。 + // 表格组、行锚点和列中心已经限定候选区域,因此首段允许从单字开始, + // 再沿同一列逐行完成;普通正文仍使用独立的连续匹配逻辑。 + const minimumInitialRun = Math.min(1, remainingLength); + let selected: + | { + lineIndex: number; + characterIndex: number; + length: number; + x: number; + leftX: number; + y: number; + } + | undefined; + let fallback: + | { + lineIndex: number; + characterIndex: number; + length: number; + x: number; + leftX: number; + y: number; + } + | undefined; + const preferBestWithinWindow = + initialLineLimit !== undefined || expectedIndex > 0 || positions.length > 0; + const continuationBaseLimit = + searchCursor.lineIndex + TABLE_CELL_CONTINUATION_LINE_TOLERANCE; + const continuationReferencePosition = lastPosition ?? positions.at(-1); + const continuationStartPage = continuationReferencePosition === undefined + ? undefined + : lines[continuationReferencePosition.lineIndex]?.pageNumber; + const crossesPageInsideContinuationWindow = + continuationStartPage !== undefined && + lines + .slice(searchCursor.lineIndex, continuationBaseLimit) + .some((line) => line.pageNumber !== continuationStartPage); + const continuationLineLimit = continuationBaseLimit + + (crossesPageInsideContinuationWindow + ? TABLE_ROW_INITIAL_LINE_TOLERANCE + : 0); + + for ( + let lineIndex = searchCursor.lineIndex; + lineIndex < lineCharacters.length && ( + positions.length === 0 && initialLineLimit !== undefined + ? lineIndex <= initialLineLimit + : expectedIndex === 0 && positions.length === 0 + ? true + : lineIndex < + continuationLineLimit + ); + lineIndex += 1 + ) { + if ( + searchingInitialChunk && + selected !== undefined && + lines[lineIndex]?.pageNumber !== lines[selected.lineIndex]?.pageNumber + ) { + break; + } + if ( + searchingInitialChunk && + initialPageNumber !== undefined && + lines[lineIndex]?.pageNumber !== initialPageNumber + ) { + continue; + } + const characters = lineCharacters[lineIndex] ?? []; + const from = lineIndex === searchCursor.lineIndex + ? searchCursor.characterIndex + : 0; + const lineCandidates: Array<{ + lineIndex: number; + characterIndex: number; + length: number; + x: number; + leftX: number; + y: number; + }> = []; + for (let characterIndex = from; characterIndex < characters.length; characterIndex += 1) { + if (characters[characterIndex] !== expectedCharacters[expectedIndex]) { + continue; + } + if (occupiedCharacters.has(`${lineIndex}:${characterIndex}`)) { + continue; + } + let length = 0; + while ( + characterIndex + length < characters.length && + expectedIndex + length < expectedCharacters.length && + !occupiedCharacters.has(`${lineIndex}:${characterIndex + length}`) && + characters[characterIndex + length] === + expectedCharacters[expectedIndex + length] + ) { + length += 1; + } + const line = lines[lineIndex]; + if (!line || length === 0) { + continue; + } + if (line.line.items.length > 0 && length > 1) { + let previousCharacterBounds: PdfPointBounds | undefined; + const knownColumnRightX = Math.max( + ...[ + initialColumnMaxX, + alternateColumnMaxX, + ].filter((value): value is number => value !== undefined), + Number.NEGATIVE_INFINITY, + ); + for (let offset = 0; offset < length; offset += 1) { + const characterBounds = matchedLineItems( + line.line, + line.normalizedText, + { + start: characterIndex + offset, + end: characterIndex + offset + 1, + }, + "table-cell", + ).bounds; + const remainsInsideKnownColumn = + Number.isFinite(knownColumnRightX) && + characterBounds.x + characterBounds.width <= + knownColumnRightX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT; + if ( + previousCharacterBounds && + characterBounds.x - + (previousCharacterBounds.x + previousCharacterBounds.width) > + line.line.bounds.height * 0.75 && + !remainsInsideKnownColumn + ) { + length = offset; + break; + } + previousCharacterBounds = characterBounds; + } + } + if (length === 0) { + continue; + } + const bounds = matchedLineItems( + line.line, + line.normalizedText, + { start: characterIndex, end: characterIndex + length }, + "table-cell", + ).bounds; + const x = bounds.x + bounds.width / 2; + const leftX = bounds.x; + const y = line.line.baselineY; + if ( + positions.length === 0 && + minimumInitialX !== undefined && + leftX < minimumInitialX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) { + continue; + } + lineCandidates.push({ lineIndex, characterIndex, length, x, leftX, y }); + } + const targetX = positions.length === 0 && expectedIndex === 0 + ? anchorX + : preferCenterContinuation + ? anchorX + : continuationLeftX ?? anchorX; + const initialChunk = positions.length === 0 && expectedIndex === 0; + const candidateX = (candidate: (typeof lineCandidates)[number]) => + preferCenterContinuation + ? candidate.x + : candidate.leftX; + const candidateRowDistance = ( + candidate: (typeof lineCandidates)[number], + ) => preferredRowY === undefined + ? 0 + : Math.abs(candidate.y - preferredRowY); + const candidateContinuationRowDistance = ( + candidate: (typeof lineCandidates)[number], + ) => preferredContinuationRowY === undefined || + preferredContinuationPageNumber === undefined || + lines[candidate.lineIndex]?.pageNumber !== + preferredContinuationPageNumber + ? Number.POSITIVE_INFINITY + : Math.abs(candidate.y - preferredContinuationRowY); + const isWithinTargetColumn = ( + candidate: (typeof lineCandidates)[number], + ) => { + if (targetX === undefined) { + return true; + } + const sameLineContinuation = !initialChunk && + continuationReferencePosition !== undefined && + candidate.lineIndex === continuationReferencePosition.lineIndex; + const boundedSameLineContinuation = sameLineContinuation && + ( + !preferCenterContinuation || + initialColumnMaxX !== undefined || + alternateColumnMaxX !== undefined + ); + if ( + (initialChunk || boundedSameLineContinuation) && + ( + minimumInitialX !== undefined || + initialColumnMinX !== undefined || + initialColumnMaxX !== undefined || + alternateColumnMinX !== undefined || + alternateColumnMaxX !== undefined + ) + ) { + const insidePrimary = + (initialColumnMinX === undefined || + candidate.x >= initialColumnMinX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) && + (initialColumnMaxX === undefined || + candidate.x <= initialColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT); + const insideAlternate = !/^\d$/u.test(expectedCharacters[0] ?? "") && + (alternateColumnMinX !== undefined || alternateColumnMaxX !== undefined) && + (alternateColumnMinX === undefined || + candidate.leftX >= alternateColumnMinX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) && + (alternateColumnMaxX === undefined || + candidate.leftX <= alternateColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT); + const insideRowDomain = + (minimumInitialX === undefined || + candidate.leftX >= minimumInitialX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) && + ( + ( + initialColumnMaxX === undefined || + candidate.x <= initialColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) || + ( + alternateColumnMaxX !== undefined && + candidate.leftX <= alternateColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) + ); + return insidePrimary || insideAlternate || insideRowDomain; + } + return Math.abs(candidateX(candidate) - targetX) <= + continuationXTolerance; + }; + lineCandidates.sort((left, right) => { + if (targetX !== undefined) { + const leftDistance = Math.abs(candidateX(left) - targetX); + const rightDistance = Math.abs(candidateX(right) - targetX); + const leftInColumn = isWithinTargetColumn(left); + const rightInColumn = isWithinTargetColumn(right); + if (leftInColumn !== rightInColumn) { + return leftInColumn ? -1 : 1; + } + return right.length - left.length || + leftDistance - rightDistance || + candidateRowDistance(left) - candidateRowDistance(right) || + left.characterIndex - right.characterIndex; + } + if (initialChunk && preserveLeftmostAcrossLines) { + return left.leftX - right.leftX || right.length - left.length; + } + return right.length - left.length || + candidateRowDistance(left) - candidateRowDistance(right) || + left.characterIndex - right.characterIndex; + }); + const bestOnLine = lineCandidates[0]; + if (!bestOnLine) { + continue; + } + const bestX = initialChunk ? bestOnLine.x : bestOnLine.leftX; + const fallbackX = initialChunk ? fallback?.x : fallback?.leftX; + const prefersSameColumn = targetX !== undefined && fallback !== undefined && + fallbackX !== undefined && + ( + Math.abs(bestX - targetX) < Math.abs(fallbackX - targetX) || + ( + Math.abs(bestX - targetX) === Math.abs(fallbackX - targetX) && + (bestOnLine.length > fallback.length || + (bestOnLine.length === fallback.length && + bestOnLine.lineIndex < fallback.lineIndex)) + ) + ); + if ( + !fallback || + (targetX === undefined && bestOnLine.length > fallback.length) || + prefersSameColumn + ) { + fallback = bestOnLine; + } + const acceptable = bestOnLine.length >= minimumInitialRun && ( + targetX === undefined || isWithinTargetColumn(bestOnLine) + ); + if (acceptable) { + if (initialChunk && preferEarliestInitialSegment) { + // 多个硬换行片段可能围绕同行首列的垂直中心分布,并共享相同 + // 首字。行列坐标已经把搜索限制在当前单元格后,首段应保留 + // 最早候选;否则靠近行中心的第二段会抢占起点,后续再串入 + // 下一表格行中的同名尾字。 + if ( + !selected || + bestOnLine.lineIndex < selected.lineIndex || + ( + bestOnLine.lineIndex === selected.lineIndex && + bestOnLine.length > selected.length + ) + ) { + selected = bestOnLine; + } + } else if (initialChunk && preferLeftmostInitial) { + // 同一行首列可能被 Office 拆成 D / WS 这类多条视觉行。 + // 当前行起点已经由上一行 high-water 限定,优先保留最早的 + // 合法首字符,再沿列方向续接;否则稍后正文中的完整同名词 + // 会仅凭更长前缀夺走行锚点,并使其余列发生级联错配。 + if ( + !selected || + bestOnLine.lineIndex < selected.lineIndex || + ( + bestOnLine.lineIndex === selected.lineIndex && + bestOnLine.length > selected.length + ) + ) { + selected = bestOnLine; + } + } else if (initialChunk && preserveLeftmostAcrossLines) { + // 首列没有历史列锚点时,以明显更靠左的候选确定列域;同一 + // 近似列内保留较早视觉行,避免稍后的长同前缀跨列抢占。 + // 窄表头的后续 ASCII 列也复用该规则,但只能在当前逻辑行 + // 的垂直域内竞争,不能被下方数据行中更靠左的同前缀夺走。 + const bestInsideRow = preferredRowY === undefined || + candidateRowDistance(bestOnLine) <= + TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT; + const selectedInsideRow = selected === undefined || + preferredRowY === undefined || + candidateRowDistance(selected) <= + TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT; + if ( + !selected || + ( + bestInsideRow && + !selectedInsideRow && + Math.abs(bestOnLine.leftX - selected.leftX) > + TABLE_COLUMN_CENTER_TOLERANCE_PT + ) || + ( + bestInsideRow === selectedInsideRow && + bestOnLine.leftX < + selected.leftX - TABLE_COLUMN_CENTER_TOLERANCE_PT && + ( + selected.length < remainingLength || + bestOnLine.length >= selected.length + ) + ) + ) { + selected = bestOnLine; + } + } else { + const selectedX = selected === undefined + ? undefined + : candidateX(selected); + const bestDistance = targetX === undefined + ? undefined + : Math.abs(candidateX(bestOnLine) - targetX); + const selectedDistance = targetX === undefined || selectedX === undefined + ? undefined + : Math.abs(selectedX - targetX); + const bestContinuationRowDistance = + candidateContinuationRowDistance(bestOnLine); + const selectedContinuationRowDistance = selected === undefined + ? Number.POSITIVE_INFINITY + : candidateContinuationRowDistance(selected); + const crossPageContinuationCandidate = + !initialChunk && + continuationReferencePosition !== undefined && + selected !== undefined && + lines[continuationReferencePosition.lineIndex]?.pageNumber !== + lines[selected.lineIndex]?.pageNumber && + lines[bestOnLine.lineIndex]?.pageNumber === + lines[selected.lineIndex]?.pageNumber && + ( + Math.abs( + (lines[bestOnLine.lineIndex]?.line.bounds.y ?? 0) - + (lines[selected.lineIndex]?.line.bounds.y ?? 0), + ) <= Math.max( + lines[bestOnLine.lineIndex]?.line.bounds.height ?? 0, + lines[selected.lineIndex]?.line.bounds.height ?? 0, + ) * 2.5 || + ( + bestOnLine.characterIndex === 0 && + bestOnLine.length === + (lineCharacters[bestOnLine.lineIndex]?.length ?? 0) + ) + ); + const sameAnchoredContinuationPageCandidate = + !initialChunk && + continuationReferencePosition !== undefined && + selected !== undefined && + preferredContinuationPageNumber !== undefined && + lines[continuationReferencePosition.lineIndex]?.pageNumber !== + preferredContinuationPageNumber && + lines[selected.lineIndex]?.pageNumber === + preferredContinuationPageNumber && + lines[bestOnLine.lineIndex]?.pageNumber === + preferredContinuationPageNumber; + const closerAnchoredContinuationCandidate = + sameAnchoredContinuationPageCandidate && + bestContinuationRowDistance < + selectedContinuationRowDistance - + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT; + const completeCrossPageHardBreakSegmentCandidate = + !initialChunk && + segments.length > 1 && + expectedIndex === 0 && + continuationReferencePosition !== undefined && + selected !== undefined && + lines[continuationReferencePosition.lineIndex]?.pageNumber !== + lines[selected.lineIndex]?.pageNumber && + lines[bestOnLine.lineIndex]?.pageNumber === + lines[selected.lineIndex]?.pageNumber && + selected.length < remainingLength && + bestOnLine.length === remainingLength; + if ( + !selected || + ( + bestOnLine.length > selected.length && + ( + initialChunk || + bestOnLine.lineIndex === selected.lineIndex || + crossPageContinuationCandidate || + closerAnchoredContinuationCandidate || + completeCrossPageHardBreakSegmentCandidate || + !preferEarliestSamePageContinuation + ) && + ( + !initialChunk || + preferredRowY === undefined || + candidateRowDistance(bestOnLine) <= + Math.max( + candidateRowDistance(selected) + + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT, + TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT, + ) + ) + ) || + ( + initialChunk && + preferredRowY !== undefined && + candidateRowDistance(selected) > + TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT && + candidateRowDistance(bestOnLine) < + candidateRowDistance(selected) - + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) || + ( + bestOnLine.length === selected.length && + initialChunk && + preferredRowY !== undefined && + candidateRowDistance(bestOnLine) < + candidateRowDistance(selected) - + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) || + ( + bestOnLine.length === selected.length && + sameAnchoredContinuationPageCandidate && + bestContinuationRowDistance < + selectedContinuationRowDistance - + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) || + ( + bestOnLine.length === selected.length && + crossPageContinuationCandidate && + bestDistance !== undefined && + selectedDistance !== undefined && + bestDistance < + selectedDistance - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT + ) || + ( + !initialChunk && + preferCenterContinuation && + bestOnLine.length === selected.length && + lines[bestOnLine.lineIndex]?.pageNumber === + lines[selected.lineIndex]?.pageNumber && + Math.abs( + (lines[bestOnLine.lineIndex]?.line.bounds.y ?? 0) - + (lines[selected.lineIndex]?.line.bounds.y ?? 0), + ) <= Math.max( + lines[bestOnLine.lineIndex]?.line.bounds.height ?? 0, + lines[selected.lineIndex]?.line.bounds.height ?? 0, + ) * 2 && + bestDistance !== undefined && + selectedDistance !== undefined && + bestDistance < selectedDistance + ) + ) { + selected = bestOnLine; + } + } + if ( + searchingInitialChunk && + !preferLeftmostInitial && + anchorX !== undefined && + expectedCharacters.length >= 2 && + expectedCharacters.slice(0, 2).every((character) => + /[A-Za-z0-9_]/u.test(character) + ) && + bestOnLine.length >= Math.min(2, remainingLength) && + ( + preferredRowY === undefined || + candidateRowDistance(bestOnLine) <= + TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT + ) + ) { + selected = bestOnLine; + break; + } + if (!preferBestWithinWindow) { + break; + } + } + } + + selected ??= anchorX === undefined && continuationLeftX === undefined + ? fallback + : undefined; + if (!selected) { + break; + } + anchorX ??= selected.x; + continuationLeftX ??= selected.leftX; + for (let offset = 0; offset < selected.length; offset += 1) { + lastPosition = { + lineIndex: selected.lineIndex, + characterIndex: selected.characterIndex + offset, + character: expectedCharacters[expectedIndex + offset]!, + }; + positions.push(lastPosition); + } + expectedIndex += selected.length; + searchCursor = expectedIndex < expectedCharacters.length + ? { + lineIndex: selected.lineIndex, + characterIndex: selected.characterIndex + selected.length, + } + : { + lineIndex: selected.lineIndex, + characterIndex: selected.characterIndex + selected.length, + }; + } + if (expectedIndex < expectedCharacters.length || !lastPosition) { + return { matched: false, positions }; + } + segmentStart = { + lineIndex: lastPosition.lineIndex, + characterIndex: lastPosition.characterIndex + 1, + }; + } + return { matched: true, positions }; +} + +function compareCursor(left: MatchCursor, right: MatchCursor): number { + return left.lineIndex - right.lineIndex || + left.characterIndex - right.characterIndex; +} + +function cursorAfterPositions( + positions: readonly MatchedCharacterPosition[], +): MatchCursor | undefined { + const last = positions.reduce( + (maximum, position) => + !maximum || + position.lineIndex > maximum.lineIndex || + ( + position.lineIndex === maximum.lineIndex && + position.characterIndex > maximum.characterIndex + ) + ? position + : maximum, + undefined, + ); + return last + ? { lineIndex: last.lineIndex, characterIndex: last.characterIndex + 1 } + : undefined; +} + +function finishActiveTable(state: ParagraphMatchState): void { + if (state.tableHighWater && compareCursor(state.tableHighWater, state.cursor) > 0) { + state.cursor.lineIndex = state.tableHighWater.lineIndex; + state.cursor.characterIndex = state.tableHighWater.characterIndex; + } + delete state.activeTableGroupId; + delete state.activeTableRowIndex; + delete state.activeTableRowAnchorLineIndex; + delete state.activeTableRowContinuationPageNumber; + delete state.activeTableRowContinuationBaselineY; + delete state.activeTableRowLastColumnIndex; + delete state.activeTableRowLastColumnCenter; + delete state.activeTableRowLastColumnRight; + delete state.tableStart; + delete state.tableRowStart; + delete state.tableHighWater; + state.tableColumnAnchors.clear(); + state.tableColumnLeftAnchors.clear(); + state.occupiedTableCharacters.clear(); +} + +function matchedPositionsBounds( + positions: readonly MatchedCharacterPosition[], + lines: readonly PdfEditableContentLine[], +): PdfPointBounds | undefined { + const firstLineIndex = positions[0]?.lineIndex; + if (firstLineIndex === undefined) { + return undefined; + } + const firstLinePositions = positions.filter( + (position) => position.lineIndex === firstLineIndex, + ); + const firstLine = lines[firstLineIndex]; + if ( + !firstLine || + firstLine.line.items.length === 0 || + firstLinePositions.length === 0 + ) { + return undefined; + } + const bounds = matchedLineItems( + firstLine.line, + firstLine.normalizedText, + { + start: Math.min(...firstLinePositions.map((position) => position.characterIndex)), + end: Math.max(...firstLinePositions.map((position) => position.characterIndex)) + 1, + }, + "table-cell", + ).bounds; + return bounds; +} + function observeParagraph( expectation: EditableParagraphExpectation, lines: readonly PdfEditableContentLine[], lineCharacters: readonly string[][], - cursor: MatchCursor, + state: ParagraphMatchState, ): PdfParagraphLayoutObservation { const expectedCharacters = Array.from( normalizePdfEditableText(expectation.text), ); + const mathCharacterIndexes = new Set(expectation.mathCharacterIndexes ?? []); const matchedByLine = new Map(); const matchedRanges = new Map(); let matchedPositions: MatchedCharacterPosition[] = []; - let matchedCursor: MatchCursor = { ...cursor }; + let matchedCursor: MatchCursor = { ...state.cursor }; let matched = expectedCharacters.length === 0; - const searchCursor: MatchCursor = { ...cursor }; - while (!matched && searchCursor.lineIndex < lineCharacters.length) { + const interleavedTableCell = + expectation.blockKind === "table-cell" || + expectation.blockKind === "table-header"; + if (interleavedTableCell) { + const tableGroupId = expectation.tableGroupId ?? "__implicit-table__"; + if (state.activeTableGroupId !== tableGroupId) { + finishActiveTable(state); + state.activeTableGroupId = tableGroupId; + state.tableStart = { ...state.cursor }; + } + const tableRowIndex = expectation.tableRowIndex; + const tableColumnIndex = expectation.tableColumnIndex; + const hasStableTableCoordinates = + tableRowIndex !== undefined && tableColumnIndex !== undefined; if ( - lineCharacters[searchCursor.lineIndex]?.[ - searchCursor.characterIndex - ] === expectedCharacters[0] + hasStableTableCoordinates && + state.activeTableRowIndex !== tableRowIndex ) { - const attempt = matchContiguousCharacters( - searchCursor, - expectedCharacters, - lineCharacters, - ); - if (attempt.positions.length > matchedPositions.length) { - matchedPositions = attempt.positions; + state.activeTableRowIndex = tableRowIndex; + delete state.activeTableRowAnchorLineIndex; + delete state.activeTableRowContinuationPageNumber; + delete state.activeTableRowContinuationBaselineY; + delete state.activeTableRowLastColumnIndex; + delete state.activeTableRowLastColumnCenter; + delete state.activeTableRowLastColumnRight; + state.tableRowStart = { + ...(state.tableHighWater ?? state.tableStart ?? state.cursor), + }; + } + const tableMatchStart = hasStableTableCoordinates + ? state.activeTableRowAnchorLineIndex === undefined + ? state.tableRowStart ?? state.tableStart ?? state.cursor + : { + lineIndex: Math.max( + state.tableRowStart?.lineIndex ?? 0, + state.activeTableRowAnchorLineIndex - + TABLE_ROW_INITIAL_LINE_TOLERANCE, + ), + characterIndex: 0, + } + : state.tableStart ?? state.cursor; + const preferredColumnX = tableColumnIndex === undefined + ? undefined + : state.tableColumnAnchors.get(tableColumnIndex); + const currentColumnLeftX = tableColumnIndex === undefined + ? undefined + : state.tableColumnLeftAnchors.get(tableColumnIndex); + const nextColumnLeftX = tableColumnIndex === undefined + ? undefined + : state.tableColumnLeftAnchors.get(tableColumnIndex + 1); + const previousColumnLeftX = tableColumnIndex === undefined + ? undefined + : state.tableColumnLeftAnchors.get(tableColumnIndex - 1); + const inferredColumnRightX = nextColumnLeftX !== undefined + ? nextColumnLeftX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT * 2 + : currentColumnLeftX !== undefined && previousColumnLeftX !== undefined + ? currentColumnLeftX + (currentColumnLeftX - previousColumnLeftX) + : undefined; + const advancesStableColumn = + hasStableTableCoordinates && + state.activeTableRowLastColumnIndex !== undefined && + tableColumnIndex !== undefined && + tableColumnIndex > state.activeTableRowLastColumnIndex; + const repeatedStableCell = + hasStableTableCoordinates && + state.activeTableRowLastColumnIndex === tableColumnIndex; + // 表头常为短文本,使用中心点可以区分居中列;正文单元格可能是很长的 + // 行内代码并跨多条视觉行,其中心会随每行长度漂移,因此正文复用已经 + // 学到的列左边界。这样同行其他列被 PDF.js 合并时也不会串列。同一稳定 + // 单元格内的连续段落可能分别居左、居中或居右,不能把首段左边界继续当成 + // 后续段落的横向锚点;字符占用与行序约束仍会阻止回退到已经匹配的段落。 + const preferredTableMatchX = repeatedStableCell + ? undefined + : expectation.blockKind === "table-header" + ? preferredColumnX + : currentColumnLeftX ?? preferredColumnX; + const attempt = matchInterleavedTableCellSegments( + tableMatchStart, + expectation.hardBreakSegments ?? [expectation.text], + lines, + lineCharacters, + state.occupiedTableCharacters, + preferredTableMatchX, + state.activeTableRowAnchorLineIndex === undefined + ? hasStableTableCoordinates + ? tableMatchStart.lineIndex + TABLE_ROW_START_SEARCH_LINE_TOLERANCE + : undefined + : state.activeTableRowAnchorLineIndex + + TABLE_ROW_INITIAL_LINE_TOLERANCE, + advancesStableColumn + ? state.activeTableRowLastColumnRight + : undefined, + undefined, + preferredColumnX === undefined || + state.tableColumnAnchors.get((tableColumnIndex ?? 0) + 1) === undefined + ? undefined + : ( + preferredColumnX + + state.tableColumnAnchors.get((tableColumnIndex ?? 0) + 1)! + ) / 2, + undefined, + inferredColumnRightX, + hasStableTableCoordinates && + tableColumnIndex === 0 && + expectedCharacters.length <= 8, + expectation.blockKind === "table-header" && + preferredColumnX === undefined, + expectation.blockKind === "table-header" + ? TABLE_HEADER_CONTINUATION_X_TOLERANCE_PT + : TABLE_COLUMN_CENTER_TOLERANCE_PT, + expectation.blockKind === "table-header", + expectation.blockKind === "table-cell" && hasStableTableCoordinates, + state.activeTableRowAnchorLineIndex === undefined || advancesStableColumn + ? undefined + : lines[state.activeTableRowAnchorLineIndex]?.pageNumber, + state.activeTableRowAnchorLineIndex === undefined + ? undefined + : lines[state.activeTableRowAnchorLineIndex]?.line.baselineY, + state.activeTableRowContinuationPageNumber, + state.activeTableRowContinuationBaselineY, + hasStableTableCoordinates && + (expectation.hardBreakSegments?.filter((segment) => + normalizePdfEditableText(segment).length > 0 + ).length ?? 0) > 2, + ); + const clippedTrailingIdentifier = + expectation.blockKind === "table-header" && + /^[A-Za-z_][A-Za-z0-9_]{11,}$/u.test(expectation.text) && + attempt.positions.length < expectedCharacters.length && + attempt.positions.length / expectedCharacters.length >= + TABLE_HEADER_MIN_CLIPPED_IDENTIFIER_COVERAGE && + attempt.positions.every( + (position, index) => position.character === expectedCharacters[index], + ) && + (() => { + const last = attempt.positions.at(-1); + return last !== undefined && + last.characterIndex === + (lineCharacters[last.lineIndex]?.length ?? 0) - 1; + })(); + matched = attempt.matched || clippedTrailingIdentifier; + matchedPositions = attempt.positions; + if (matched) { + for (const position of matchedPositions) { + state.occupiedTableCharacters.add( + `${position.lineIndex}:${position.characterIndex}`, + ); } - if (attempt.positions.length === expectedCharacters.length) { - matched = true; - matchedPositions = attempt.positions; - matchedCursor = attempt.cursor; - break; + const highWater = cursorAfterPositions(matchedPositions); + if ( + highWater && + (!state.tableHighWater || compareCursor(highWater, state.tableHighWater) > 0) + ) { + state.tableHighWater = highWater; + } + if ( + hasStableTableCoordinates && + state.activeTableRowAnchorLineIndex === undefined + ) { + state.activeTableRowAnchorLineIndex = Math.min( + ...matchedPositions.map((position) => position.lineIndex), + ); + } + if (hasStableTableCoordinates && state.activeTableRowAnchorLineIndex !== undefined) { + const anchorPageNumber = + lines[state.activeTableRowAnchorLineIndex]?.pageNumber; + const continuationPositions = matchedPositions.filter((position) => + anchorPageNumber !== undefined && + (lines[position.lineIndex]?.pageNumber ?? anchorPageNumber) > + anchorPageNumber + ); + const continuationPageNumber = continuationPositions.reduce( + (maximum, position) => Math.max( + maximum, + lines[position.lineIndex]?.pageNumber ?? maximum, + ), + state.activeTableRowContinuationPageNumber ?? anchorPageNumber ?? 0, + ); + const continuationLine = continuationPositions + .filter((position) => + lines[position.lineIndex]?.pageNumber === continuationPageNumber + ) + .reduce( + (minimum, position) => + minimum === undefined || position.lineIndex < minimum.lineIndex + ? position + : minimum, + undefined, + ); + if (continuationLine !== undefined) { + const continuationBaselineY = + lines[continuationLine.lineIndex]?.line.baselineY; + if (continuationBaselineY !== undefined) { + state.activeTableRowContinuationPageNumber = continuationPageNumber; + state.activeTableRowContinuationBaselineY = continuationBaselineY; + } + } + } + const matchedBounds = matchedPositionsBounds(matchedPositions, lines); + if (hasStableTableCoordinates && matchedBounds !== undefined) { + state.activeTableRowLastColumnIndex = tableColumnIndex; + state.activeTableRowLastColumnCenter = + matchedBounds.x + matchedBounds.width / 2; + state.activeTableRowLastColumnRight = + matchedBounds.x + matchedBounds.width; + } + if ( + tableColumnIndex !== undefined && + !state.tableColumnAnchors.has(tableColumnIndex) + ) { + if (matchedBounds !== undefined) { + state.tableColumnAnchors.set( + tableColumnIndex, + matchedBounds.x + matchedBounds.width / 2, + ); + state.tableColumnLeftAnchors.set(tableColumnIndex, matchedBounds.x); + } + } else if (tableColumnIndex !== undefined && matchedBounds !== undefined) { + state.tableColumnLeftAnchors.set( + tableColumnIndex, + Math.min( + state.tableColumnLeftAnchors.get(tableColumnIndex) ?? + matchedBounds.x, + matchedBounds.x, + ), + ); } } - if (!advanceCursor(searchCursor, lineCharacters)) { - break; + } else { + finishActiveTable(state); + const searchCursor: MatchCursor = { ...state.cursor }; + while (!matched && searchCursor.lineIndex < lineCharacters.length) { + if ( + (lineCharacters[searchCursor.lineIndex]?.[ + searchCursor.characterIndex + ] === expectedCharacters[0] || + (mathCharacterIndexes.has(0) && + /^\p{L}$/u.test( + lineCharacters[searchCursor.lineIndex]?.[ + searchCursor.characterIndex + ] ?? "", + ) && + /^\p{L}$/u.test(expectedCharacters[0] ?? ""))) + ) { + const attempt = matchContiguousCharacters( + searchCursor, + expectedCharacters, + lineCharacters, + mathCharacterIndexes, + ); + if (attempt.positions.length > matchedPositions.length) { + matchedPositions = attempt.positions; + } + if (attempt.positions.length === expectedCharacters.length) { + matched = true; + matchedPositions = attempt.positions; + matchedCursor = attempt.cursor; + break; + } + } + if (!advanceCursor(searchCursor, lineCharacters)) { + break; + } } } @@ -284,6 +1306,7 @@ function observeParagraph( line.line, line.normalizedText, matchedRanges.get(matchedLineIndexes[index]!), + expectation.blockKind, ); return { pageNumber: line.pageNumber, @@ -297,9 +1320,9 @@ function observeParagraph( ].sort(), }; }); - if (matched) { - cursor.lineIndex = matchedCursor.lineIndex; - cursor.characterIndex = matchedCursor.characterIndex; + if (matched && !interleavedTableCell) { + state.cursor.lineIndex = matchedCursor.lineIndex; + state.cursor.characterIndex = matchedCursor.characterIndex; } return { matched, @@ -386,6 +1409,8 @@ function compareParagraph( candidate: PdfParagraphLayoutObservation, baselineCoverPageCount: number, candidateCoverPageCount: number, + baselineColumnLeftX?: number, + candidateColumnLeftX?: number, ): VisualDiffIssue[] { const issues: VisualDiffIssue[] = []; if (!baseline.matched || !candidate.matched) { @@ -524,7 +1549,20 @@ function compareParagraph( } if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) { - const xDelta = delta(baseline.firstLineXPt, candidate.firstLineXPt); + const columnRelativeGeometry = + expectation.tableGroupId !== undefined && + expectation.tableColumnIndex !== undefined && + baselineColumnLeftX !== undefined && + candidateColumnLeftX !== undefined; + const baselineGeometryX = baseline.firstLineXPt === undefined + ? undefined + : baseline.firstLineXPt - + (columnRelativeGeometry ? baselineColumnLeftX : 0); + const candidateGeometryX = candidate.firstLineXPt === undefined + ? undefined + : candidate.firstLineXPt - + (columnRelativeGeometry ? candidateColumnLeftX : 0); + const xDelta = delta(baselineGeometryX, candidateGeometryX); const xTolerancePt = expectation.section === "cover" ? COVER_LINE_X_TOLERANCE_PT @@ -554,6 +1592,7 @@ function compareParagraph( paragraphIndex: expectation.index, xDeltaPt: xDelta ?? 0, xTolerancePt, + columnRelativeGeometry, widthDeltaPt: widthDelta ?? 0, widthDeltaDiagnosticOnly: true, lineHeightDeltaPt: heightDelta ?? 0, @@ -601,21 +1640,31 @@ export function comparePdfEditableParagraphLayouts( const candidateCoverPageCount = candidatePageSemantics.filter( (page) => page.kind === "cover", ).length; - const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 }; - const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 }; + const baselineState: ParagraphMatchState = { + cursor: { lineIndex: 0, characterIndex: 0 }, + tableColumnAnchors: new Map(), + tableColumnLeftAnchors: new Map(), + occupiedTableCharacters: new Set(), + }; + const candidateState: ParagraphMatchState = { + cursor: { lineIndex: 0, characterIndex: 0 }, + tableColumnAnchors: new Map(), + tableColumnLeftAnchors: new Map(), + occupiedTableCharacters: new Set(), + }; return expectations.map((expectation) => { const baselineObservation = observeParagraph( expectation, baselineLines, baselineCharacters, - baselineCursor, + baselineState, ); const candidateObservation = observeParagraph( expectation, candidateLines, candidateCharacters, - candidateCursor, + candidateState, ); const issues = compareParagraph( expectation, @@ -623,6 +1672,12 @@ export function comparePdfEditableParagraphLayouts( candidateObservation, baselineCoverPageCount, candidateCoverPageCount, + expectation.tableColumnIndex === undefined + ? undefined + : baselineState.tableColumnLeftAnchors.get(expectation.tableColumnIndex), + expectation.tableColumnIndex === undefined + ? undefined + : candidateState.tableColumnLeftAnchors.get(expectation.tableColumnIndex), ); return { expectation, diff --git a/packages/document-visual-diff/src/raster.ts b/packages/document-visual-diff/src/raster.ts index 6cdc66a..f230418 100644 --- a/packages/document-visual-diff/src/raster.ts +++ b/packages/document-visual-diff/src/raster.ts @@ -33,12 +33,35 @@ async function decodeRaster( width: number, height: number, ): Promise { - const image = await loadImage(Buffer.from(raster.png)); + if ( + raster.rgba?.length === raster.widthPx * raster.heightPx * 4 && + raster.widthPx === width && + raster.heightPx === height + ) { + return { + width, + height, + rgba: raster.rgba, + }; + } const canvas = createCanvas(width, height); const context = canvas.getContext("2d"); context.fillStyle = "#ffffff"; context.fillRect(0, 0, width, height); - context.drawImage(image, 0, 0, width, height); + if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) { + const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx); + const sourceContext = sourceCanvas.getContext("2d"); + const sourceData = sourceContext.createImageData( + raster.widthPx, + raster.heightPx, + ); + sourceData.data.set(raster.rgba); + sourceContext.putImageData(sourceData, 0, 0); + context.drawImage(sourceCanvas, 0, 0, width, height); + } else { + const image = await loadImage(Buffer.from(raster.png)); + context.drawImage(image, 0, 0, width, height); + } return { width, height, @@ -65,11 +88,66 @@ function colorDistance(left: RgbColor, right: RgbColor): number { ); } +function colorChroma(color: RgbColor): number { + return Math.max(color.red, color.green, color.blue) - + Math.min(color.red, color.green, color.blue); +} + +function colorLuminance(color: RgbColor): number { + return (77 * color.red + 150 * color.green + 29 * color.blue) / 256; +} + function rasterBackgroundColor( rgba: Uint8ClampedArray, - _width: number, - _height: number, + width: number, + height: number, ): RgbColor { + const red: number[] = []; + const green: number[] = []; + const blue: number[] = []; + const append = (x: number, y: number) => { + const offset = (y * width + x) * 4; + red.push(rgba[offset] ?? 255); + green.push(rgba[offset + 1] ?? 255); + blue.push(rgba[offset + 2] ?? 255); + }; + for (let x = 0; x < width; x += 1) { + append(x, 0); + if (height > 1) { + append(x, height - 1); + } + } + for (let y = 1; y < height - 1; y += 1) { + append(0, y); + if (width > 1) { + append(width - 1, y); + } + } + const median = (values: number[]) => { + if (values.length === 0) { + return 255; + } + values.sort((left, right) => left - right); + return values[Math.floor(values.length / 2)] ?? 255; + }; + // 局部语义块会紧贴文字裁剪;连续渐变会把同一背景拆散到许多颜色桶, + // 使深色文字反而成为“众数背景”。边框中位色能稳定覆盖纯色和渐变, + // 且不会被少量触边字形反转。 + const border = { + red: median(red), + green: median(green), + blue: median(blue), + }; + const robustRange = (values: readonly number[]) => + (values[Math.floor(values.length * 0.8)] ?? 255) - + (values[Math.floor(values.length * 0.2)] ?? 255); + const borderChannelRange = Math.max( + robustRange(red), + robustRange(green), + robustRange(blue), + ); + // 极紧的字形裁剪可能四边都落在抗锯齿像素上;实心表头等 + // 盒背景也可能在裁剪边界外露出白色。因此保留全图众数作为常规背景。 const buckets = new Map< number, { count: number; red: number; green: number; blue: number } @@ -78,10 +156,12 @@ function rasterBackgroundColor( | { count: number; red: number; green: number; blue: number } | undefined; for (let offset = 0; offset < rgba.length; offset += 4) { - const red = rgba[offset] ?? 255; - const green = rgba[offset + 1] ?? 255; - const blue = rgba[offset + 2] ?? 255; - const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3); + const pixelRed = rgba[offset] ?? 255; + const pixelGreen = rgba[offset + 1] ?? 255; + const pixelBlue = rgba[offset + 2] ?? 255; + const bucketKey = (pixelRed >> 3) << 10 | + (pixelGreen >> 3) << 5 | + (pixelBlue >> 3); const bucket = buckets.get(bucketKey) ?? { count: 0, red: 0, @@ -89,21 +169,28 @@ function rasterBackgroundColor( blue: 0, }; bucket.count += 1; - bucket.red += red; - bucket.green += green; - bucket.blue += blue; + bucket.red += pixelRed; + bucket.green += pixelGreen; + bucket.blue += pixelBlue; buckets.set(bucketKey, bucket); if (!dominant || bucket.count > dominant.count) { dominant = bucket; } } - return !dominant - ? { red: 255, green: 255, blue: 255 } - : { + const dominantColor = dominant + ? { red: dominant.red / dominant.count, green: dominant.green / dominant.count, blue: dominant.blue / dominant.count, - }; + } + : border; + // 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变 + // 把深色文字拆分成了错误的“众数背景”。 + return borderChannelRange <= 64 && + colorLuminance(border) >= 220 && + colorLuminance(dominantColor) <= colorLuminance(border) - 80 + ? border + : dominantColor; } function rasterForegroundColor( @@ -172,6 +259,183 @@ function rasterForegroundColor( }; } +function rasterDominantForegroundColor( + decoded: DecodedRaster, +): RgbColor { + const background = rasterBackgroundColor( + decoded.rgba, + decoded.width, + decoded.height, + ); + const buckets = new Map< + number, + { count: number; red: number; green: number; blue: number } + >(); + for (let offset = 0; offset < decoded.rgba.length; offset += 4) { + const color = { + red: decoded.rgba[offset] ?? 255, + green: decoded.rgba[offset + 1] ?? 255, + blue: decoded.rgba[offset + 2] ?? 255, + }; + if (colorDistance(color, background) < 48) { + continue; + } + const bucketKey = (color.red >> 3) << 10 | + (color.green >> 3) << 5 | + (color.blue >> 3); + const bucket = buckets.get(bucketKey) ?? { + count: 0, + red: 0, + green: 0, + blue: 0, + }; + bucket.count += 1; + bucket.red += color.red; + bucket.green += color.green; + bucket.blue += color.blue; + buckets.set(bucketKey, bucket); + } + const dominant = Array.from(buckets.values()).sort( + (left, right) => right.count - left.count, + )[0]; + return !dominant + ? background + : { + red: dominant.red / dominant.count, + green: dominant.green / dominant.count, + blue: dominant.blue / dominant.count, + }; +} + +function rasterDominantChromaticForegroundColor( + decoded: DecodedRaster, +): RgbColor | undefined { + const background = rasterBackgroundColor( + decoded.rgba, + decoded.width, + decoded.height, + ); + const buckets = new Map< + number, + { count: number; red: number; green: number; blue: number } + >(); + for (let offset = 0; offset < decoded.rgba.length; offset += 4) { + const color = { + red: decoded.rgba[offset] ?? 255, + green: decoded.rgba[offset + 1] ?? 255, + blue: decoded.rgba[offset + 2] ?? 255, + }; + if ( + colorDistance(color, background) < 48 || + colorChroma(color) < 32 + ) { + continue; + } + const bucketKey = (color.red >> 3) << 10 | + (color.green >> 3) << 5 | + (color.blue >> 3); + const bucket = buckets.get(bucketKey) ?? { + count: 0, + red: 0, + green: 0, + blue: 0, + }; + bucket.count += 1; + bucket.red += color.red; + bucket.green += color.green; + bucket.blue += color.blue; + buckets.set(bucketKey, bucket); + } + const dominant = Array.from(buckets.values()).sort( + (left, right) => right.count - left.count, + )[0]; + return dominant + ? { + red: dominant.red / dominant.count, + green: dominant.green / dominant.count, + blue: dominant.blue / dominant.count, + } + : undefined; +} + +function rasterChromaticPalette(decoded: DecodedRaster) { + const background = rasterBackgroundColor( + decoded.rgba, + decoded.width, + decoded.height, + ); + const buckets = new Map< + number, + { count: number; red: number; green: number; blue: number } + >(); + for (let offset = 0; offset < decoded.rgba.length; offset += 4) { + const color = { + red: decoded.rgba[offset] ?? 255, + green: decoded.rgba[offset + 1] ?? 255, + blue: decoded.rgba[offset + 2] ?? 255, + }; + if ( + colorDistance(color, background) < 48 || + colorChroma(color) < 32 + ) { + continue; + } + const bucketKey = (color.red >> 3) << 10 | + (color.green >> 3) << 5 | + (color.blue >> 3); + const bucket = buckets.get(bucketKey) ?? { + count: 0, + red: 0, + green: 0, + blue: 0, + }; + bucket.count += 1; + bucket.red += color.red; + bucket.green += color.green; + bucket.blue += color.blue; + buckets.set(bucketKey, bucket); + } + return Array.from(buckets.values()).map((bucket) => ({ + count: bucket.count, + color: { + red: bucket.red / bucket.count, + green: bucket.green / bucket.count, + blue: bucket.blue / bucket.count, + }, + })); +} + +function chromaticPaletteDistance( + baseline: DecodedRaster, + candidate: DecodedRaster, +) { + const baselinePalette = rasterChromaticPalette(baseline); + const candidatePalette = rasterChromaticPalette(candidate); + if (baselinePalette.length === 0 || candidatePalette.length === 0) { + return undefined; + } + const directionalDistance = ( + source: ReturnType, + target: ReturnType, + ) => { + let weightedDistance = 0; + let totalWeight = 0; + for (const entry of source) { + weightedDistance += entry.count * Math.min( + ...target.map((candidateEntry) => + colorDistance(entry.color, candidateEntry.color) + ), + ); + totalWeight += entry.count; + } + return weightedDistance / totalWeight; + }; + return Math.max( + directionalDistance(baselinePalette, candidatePalette), + directionalDistance(candidatePalette, baselinePalette), + ); +} + function clearMaskBorder( mask: Uint8Array, width: number, @@ -427,16 +691,16 @@ export async function comparePageRasters( !Number.isSafeInteger(height) || height <= 0) { throw new Error("栅格比较尺寸必须是正整数"); } - const [baselineDecoded, candidateDecoded] = await Promise.all([ - decodeRaster(baseline, width, height), - decodeRaster(candidate, width, height), - ]); + const baselineDecoded = await decodeRaster(baseline, width, height); + const candidateDecoded = await 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); + const baselineForegroundInk = new Uint8Array(pixelCount); + const candidateForegroundInk = new Uint8Array(pixelCount); const baselineBackground = rasterBackgroundColor( baselineDecoded.rgba, width, @@ -447,6 +711,8 @@ export async function comparePageRasters( width, height, ); + const baselineForeground = rasterForegroundColor(baselineDecoded); + const candidateForeground = rasterForegroundColor(candidateDecoded); let absoluteError = 0; let changedPixels = 0; @@ -494,10 +760,34 @@ export async function comparePageRasters( if (colorDistance(candidateColor, candidateBackground) >= 16) { candidateInk[pixelIndex] = 1; } + const baselineBackgroundDistance = colorDistance( + baselineColor, + baselineBackground, + ); + if ( + baselineBackgroundDistance >= 32 && + colorDistance(baselineColor, baselineForeground) < + baselineBackgroundDistance + ) { + baselineForegroundInk[pixelIndex] = 1; + } + const candidateBackgroundDistance = colorDistance( + candidateColor, + candidateBackground, + ); + if ( + candidateBackgroundDistance >= 32 && + colorDistance(candidateColor, candidateForeground) < + candidateBackgroundDistance + ) { + candidateForegroundInk[pixelIndex] = 1; + } } clearMaskBorder(baselineInk, width, height); clearMaskBorder(candidateInk, width, height); + clearMaskBorder(baselineForegroundInk, width, height); + clearMaskBorder(candidateForegroundInk, width, height); const baselineEdges = clearMaskBorder( createEdgeMask(baselineGray, width, height, 40), width, @@ -509,8 +799,27 @@ export async function comparePageRasters( height, ); const foregroundColorDelta = colorDistance( - rasterForegroundColor(baselineDecoded), - rasterForegroundColor(candidateDecoded), + baselineForeground, + candidateForeground, + ); + const dominantForegroundColorDelta = colorDistance( + rasterDominantForegroundColor(baselineDecoded), + rasterDominantForegroundColor(candidateDecoded), + ); + const baselineChromaticForeground = + rasterDominantChromaticForegroundColor(baselineDecoded); + const candidateChromaticForeground = + rasterDominantChromaticForegroundColor(candidateDecoded); + const chromaticForegroundColorDelta = + baselineChromaticForeground && candidateChromaticForeground + ? colorDistance( + baselineChromaticForeground, + candidateChromaticForeground, + ) + : undefined; + const chromaticPaletteDelta = chromaticPaletteDistance( + baselineDecoded, + candidateDecoded, ); const backgroundColorDelta = colorDistance( baselineBackground, @@ -537,6 +846,13 @@ export async function comparePageRasters( height, spatialTolerancePx, ), + foregroundInkIou: calculateSpatiallyTolerantIou( + baselineForegroundInk, + candidateForegroundInk, + width, + height, + spatialTolerancePx, + ), edgeIou: calculateSpatiallyTolerantIou( baselineEdges, candidateEdges, @@ -546,6 +862,19 @@ export async function comparePageRasters( ), backgroundColorDelta, foregroundColorDelta, + baselineForegroundChroma: colorChroma(baselineForeground), + candidateForegroundChroma: colorChroma(candidateForeground), + foregroundLuminanceDelta: Math.abs( + colorLuminance(baselineForeground) - + colorLuminance(candidateForeground), + ), + dominantForegroundColorDelta, + ...(chromaticForegroundColorDelta !== undefined + ? { chromaticForegroundColorDelta } + : {}), + ...(chromaticPaletteDelta !== undefined + ? { chromaticPaletteDelta } + : {}), }; return { metrics, diff --git a/packages/document-visual-diff/src/semantic-block.ts b/packages/document-visual-diff/src/semantic-block.ts index cc4e505..e3e7ddd 100644 --- a/packages/document-visual-diff/src/semantic-block.ts +++ b/packages/document-visual-diff/src/semantic-block.ts @@ -14,7 +14,27 @@ import type { VisualDiffThresholds, } from "./types.js"; -const BLOCK_RASTER_PADDING_PT = 4; +const BLOCK_RASTER_HORIZONTAL_PADDING_PT = 4; +const BLOCK_RASTER_VERTICAL_PADDING_PT = 1; +const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const; + +function assertPngRaster( + raster: PdfPageRaster, + description: string, +): void { + const valid = PNG_SIGNATURE.every( + (value, index) => raster.png[index] === value, + ); + if (!valid) { + const header = Array.from(raster.png.slice(0, 16)) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); + throw new Error( + `${description} 不是有效 PNG:${raster.widthPx}x${raster.heightPx},` + + `${raster.png.byteLength} 字节,头部 ${header}`, + ); + } +} function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); @@ -48,6 +68,73 @@ function collectFonts( ].sort(); } +interface TextLineSpan { + lineIndex: number; + start: number; + end: number; +} + +interface ReflowedTextLinePair { + baselineLineIndex: number; + candidateLineIndex: number; + baselineStart: number; + baselineEnd: number; + candidateStart: number; + candidateEnd: number; +} + +function buildTextLineSpans(lineTexts: readonly string[]): TextLineSpan[] { + let offset = 0; + return lineTexts.map((text, lineIndex) => { + const start = offset; + offset += Array.from(text).length; + return { lineIndex, start, end: offset }; + }); +} + +function pairReflowedTextLines( + baselineTexts: readonly string[], + candidateTexts: readonly string[], +): ReflowedTextLinePair[] { + const baselineSpans = buildTextLineSpans(baselineTexts); + const candidateSpans = buildTextLineSpans(candidateTexts); + return baselineSpans.flatMap((baseline) => + candidateSpans.flatMap((candidate) => + Math.max(baseline.start, candidate.start) < + Math.min(baseline.end, candidate.end) + ? (() => { + const overlapStart = Math.max(baseline.start, candidate.start); + const overlapEnd = Math.min(baseline.end, candidate.end); + return [{ + baselineLineIndex: baseline.lineIndex, + candidateLineIndex: candidate.lineIndex, + baselineStart: overlapStart - baseline.start, + baselineEnd: overlapEnd - baseline.start, + candidateStart: overlapStart - candidate.start, + candidateEnd: overlapEnd - candidate.start, + }]; + })() + : [], + ), + ); +} + +function sliceTextLineBounds( + bounds: PdfPointBounds, + text: string, + start: number, + end: number, +): PdfPointBounds { + const length = Math.max(1, Array.from(text).length); + const leftRatio = Math.max(0, Math.min(1, start / length)); + const rightRatio = Math.max(leftRatio, Math.min(1, end / length)); + return { + ...bounds, + x: bounds.x + bounds.width * leftRatio, + width: Math.max(0, bounds.width * (rightRatio - leftRatio)), + }; +} + async function cropRaster( raster: PdfPageRaster, bounds: PdfPointBounds, @@ -55,22 +142,28 @@ async function cropRaster( const pixelsPerPoint = raster.dpi / 72; const left = Math.max( 0, - Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint), + Math.floor( + (bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint, + ), ); const top = Math.max( 0, - Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint), + Math.floor( + (bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint, + ), ); const right = Math.min( raster.widthPx, Math.ceil( - (bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint, + (bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) * + pixelsPerPoint, ), ); const bottom = Math.min( raster.heightPx, Math.ceil( - (bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint, + (bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) * + pixelsPerPoint, ), ); const widthPx = Math.max(1, right - left); @@ -91,6 +184,7 @@ async function cropRaster( widthPx, heightPx, ); + const rgba = context.getImageData(0, 0, widthPx, heightPx).data; const png = Uint8Array.from(canvas.toBuffer("image/png")); return { widthPx, @@ -98,6 +192,7 @@ async function cropRaster( dpi: raster.dpi, sha256: sha256(png), png, + rgba, }; } @@ -109,12 +204,25 @@ async function padRaster( if (raster.widthPx === widthPx && raster.heightPx === heightPx) { return raster; } - const source = await loadImage(Buffer.from(raster.png)); const canvas = createCanvas(widthPx, heightPx); const context = canvas.getContext("2d"); context.fillStyle = "#ffffff"; context.fillRect(0, 0, widthPx, heightPx); - context.drawImage(source, 0, 0); + if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) { + const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx); + const sourceContext = sourceCanvas.getContext("2d"); + const sourceData = sourceContext.createImageData( + raster.widthPx, + raster.heightPx, + ); + sourceData.data.set(raster.rgba); + sourceContext.putImageData(sourceData, 0, 0); + context.drawImage(sourceCanvas, 0, 0); + } else { + const source = await loadImage(Buffer.from(raster.png)); + context.drawImage(source, 0, 0); + } + const rgba = context.getImageData(0, 0, widthPx, heightPx).data; const png = Uint8Array.from(canvas.toBuffer("image/png")); return { widthPx, @@ -122,6 +230,7 @@ async function padRaster( dpi: raster.dpi, sha256: sha256(png), png, + rgba, }; } @@ -129,6 +238,7 @@ function localRasterIssues( paragraphIndex: number, lineIndex: number, blockKind: string | undefined, + hasInlineCode: boolean, metrics: NonNullable, thresholds: VisualDiffThresholds, textReflow: boolean, @@ -136,9 +246,12 @@ function localRasterIssues( const shapeFailed = metrics.inkIou < thresholds.minInkIou || metrics.edgeIou < thresholds.minEdgeIou; + const neutralTinyGlyphAntialiasEquivalent = + isNeutralTinyGlyphAntialiasEquivalent(metrics); const colorFailed = metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta || - metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta; + (metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta && + !neutralTinyGlyphAntialiasEquivalent); const rawPixelsFailed = metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError || metrics.changedPixelRatio > thresholds.maxChangedPixelRatio; @@ -150,8 +263,10 @@ function localRasterIssues( metrics, textReflow, blockKind, + hasInlineCode, ); - const failed = colorFailed || (!textReflow && ( + const failed = (colorFailed && !crossEngineRasterEquivalent) || + (!textReflow && ( (shapeFailed && !crossEngineRasterEquivalent) || (rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent) )); @@ -174,12 +289,81 @@ function localRasterIssues( foregroundColorDelta: metrics.foregroundColorDelta, antialiasEquivalent, crossEngineRasterEquivalent, + neutralTinyGlyphAntialiasEquivalent, textReflow, }, }, ]; } +function isNeutralTinyGlyphAntialiasEquivalent( + metrics: NonNullable, +) { + // 极细的中性标点(例如表格占位长横线)在 WPS 中可能只保留一层 + // 灰阶覆盖,前景色估算因此会比 Chromium/Word 明显变亮。只有局部 + // 几何、墨迹和边缘完全同拓扑,且整体与背景误差都很低时才视作同一 + // 字形的抗锯齿差异;真实着色、轮廓或背景变化仍继续阻断。 + return Math.max( + metrics.baselineWidthPx, + metrics.candidateWidthPx, + ) <= 64 && + Math.max( + metrics.baselineHeightPx, + metrics.candidateHeightPx, + ) <= 48 && + metrics.inkIou >= 0.995 && + (metrics.foregroundInkIou ?? metrics.inkIou) >= 0.995 && + metrics.edgeIou >= 0.995 && + metrics.meanAbsoluteError <= 16 && + metrics.backgroundColorDelta <= 12 && + (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 && + (metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 && + (metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 120; +} + +function isNeutralForegroundToneEquivalent( + metrics: NonNullable, +) { + return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 && + (metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 && + (metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5; +} + +function isChromaticPaletteEquivalent( + metrics: NonNullable, +) { + return metrics.inkIou >= 0.995 && + metrics.edgeIou >= 0.995 && + (metrics.baselineForegroundChroma ?? 0) >= 80 && + (metrics.candidateForegroundChroma ?? 0) >= 80 && + (metrics.dominantForegroundColorDelta ?? Number.POSITIVE_INFINITY) <= 4; +} + +function isSyntaxPaletteEquivalent( + metrics: NonNullable, + blockKind?: string, +) { + if (blockKind !== "code-block") { + return false; + } + const widthDelta = Math.abs( + metrics.baselineWidthPx - metrics.candidateWidthPx, + ); + const heightDelta = Math.abs( + metrics.baselineHeightPx - metrics.candidateHeightPx, + ); + return widthDelta <= Math.max( + 4, + Math.ceil(metrics.baselineWidthPx * 0.015), + ) && + heightDelta <= 1 && + metrics.inkIou >= 0.985 && + (metrics.foregroundInkIou ?? 0) >= 0.995 && + metrics.edgeIou >= 0.995 && + metrics.backgroundColorDelta <= 4 && + (metrics.chromaticPaletteDelta ?? Number.POSITIVE_INFINITY) <= 2; +} + /** * Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。 * 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为 @@ -189,6 +373,7 @@ export function isCrossEngineRasterEquivalent( metrics: NonNullable, textReflow: boolean, blockKind?: string, + hasInlineCode = false, ): boolean { if (textReflow) { return false; @@ -215,10 +400,27 @@ export function isCrossEngineRasterEquivalent( Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 && widthDelta <= 2 && heightDelta <= 1; + const neutralTinyGlyphAntialiasEquivalent = + isNeutralTinyGlyphAntialiasEquivalent(metrics); const colorEquivalent = - metrics.backgroundColorDelta <= 4 && - metrics.foregroundColorDelta <= - (blockKind === "list-item" ? 4.5 : 4); + neutralTinyGlyphAntialiasEquivalent || + ( + metrics.backgroundColorDelta <= 4 && + (metrics.foregroundColorDelta <= + (blockKind === "list-item" ? 4.5 : 4) || + isNeutralForegroundToneEquivalent(metrics) || + isChromaticPaletteEquivalent(metrics) || + isSyntaxPaletteEquivalent(metrics, blockKind)) + ); + const inlineCodeShadingEquivalent = + hasInlineCode && + widthDelta <= Math.max(3, Math.ceil(metrics.baselineWidthPx * 0.015)) && + heightDelta <= 2 && + metrics.edgeIou >= 0.995 && + (metrics.inkIou >= 0.9 || + (metrics.foregroundInkIou ?? 0) >= 0.98) && + metrics.foregroundColorDelta <= 3.25 && + metrics.backgroundColorDelta <= 12; const topologyEquivalent = (preciseGeometryEquivalent && metrics.inkIou >= 0.99 && @@ -247,7 +449,8 @@ export function isCrossEngineRasterEquivalent( (preciseGeometryEquivalent && metrics.edgeIou >= 0.97 && metrics.inkIou >= 0.97); - return colorEquivalent && topologyEquivalent; + return inlineCodeShadingEquivalent || + (colorEquivalent && topologyEquivalent); } export async function comparePdfSemanticBlockVisuals( @@ -270,14 +473,41 @@ export async function comparePdfSemanticBlockVisuals( "paragraph", "list-item", "block-quote", + "code-block", ].includes(paragraph.expectation.blockKind ?? "paragraph"); const comparableLineCount = Math.min( paragraph.baseline.visualLines.length, paragraph.candidate.visualLines.length, ); + const hasUsableVisualMapping = (layout: typeof paragraph.baseline) => { + if (layout.matched) { + return true; + } + const expected = layout.expectedCharacterCount ?? 0; + const matched = layout.matchedCharacterCount ?? 0; + const coverage = expected === 0 ? 0 : matched / expected; + const mathCharacterIndexes = new Set( + paragraph.expectation.mathCharacterIndexes ?? [], + ); + const missingTailIsMath = + matched < expected && + Array.from( + { length: expected - matched }, + (_, offset) => matched + offset, + ).every((index) => mathCharacterIndexes.has(index)); + return layout.visualLines.length > 0 && + ((expected >= 10 && expected - matched === 1 && coverage >= 0.9) || + (missingTailIsMath && coverage >= 0.8)); + }; + const equalLineSegmentation = + paragraph.baseline.visualLines.length === + paragraph.candidate.visualLines.length && + paragraph.baseline.lineTexts.every( + (text, index) => text === paragraph.candidate.lineTexts[index], + ); if ( - !paragraph.baseline.matched || - !paragraph.candidate.matched || + !hasUsableVisualMapping(paragraph.baseline) || + !hasUsableVisualMapping(paragraph.candidate) || comparableLineCount === 0 ) { issues.push({ @@ -286,10 +516,7 @@ export async function comparePdfSemanticBlockVisuals( message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`, details: { paragraphIndex: paragraph.expectation.index }, }); - } else if ( - paragraph.baseline.visualLines.length === - paragraph.candidate.visualLines.length - ) { + } else if (equalLineSegmentation) { for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) { const baselineLine = paragraph.baseline.visualLines[lineIndex]; const candidateLine = paragraph.candidate.visualLines[lineIndex]; @@ -327,41 +554,67 @@ export async function comparePdfSemanticBlockVisuals( cropRaster(baselinePage.raster, baselineLine.bounds), cropRaster(candidatePage.raster, candidateLine.bounds), ]); + assertPngRaster( + baselineCrop, + `第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行基线裁剪`, + ); + assertPngRaster( + candidateCrop, + `第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行候选裁剪`, + ); const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? ""; const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? ""; const textReflow = flowTextBlock && baselineLineText !== candidateLineText; - const result = flowTextBlock && !textReflow - ? await comparePageRasters(baselineCrop, candidateCrop, { - pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, - spatialTolerancePx: thresholds.spatialTolerancePx, - targetWidthPx: baselineCrop.widthPx, - targetHeightPx: baselineCrop.heightPx, - geometryNormalized: true, - }) - : await (async () => { - const widthPx = Math.max( - baselineCrop.widthPx, - candidateCrop.widthPx, - ); - const heightPx = Math.max( - baselineCrop.heightPx, - candidateCrop.heightPx, - ); - const [baselinePadded, candidatePadded] = await Promise.all([ - padRaster(baselineCrop, widthPx, heightPx), - padRaster(candidateCrop, widthPx, heightPx), - ]); - return comparePageRasters( - baselinePadded, - candidatePadded, - thresholds.pixelDifferenceThreshold, - ); - })(); + const cropWidthRatio = Math.min( + baselineCrop.widthPx, + candidateCrop.widthPx, + ) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx); + const normalizeFlowGeometry = flowTextBlock && + !textReflow && + cropWidthRatio >= 0.75; + let result; + try { + result = normalizeFlowGeometry + ? await comparePageRasters(baselineCrop, candidateCrop, { + pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, + spatialTolerancePx: thresholds.spatialTolerancePx, + targetWidthPx: baselineCrop.widthPx, + targetHeightPx: baselineCrop.heightPx, + geometryNormalized: true, + }) + : await (async () => { + const widthPx = Math.max( + baselineCrop.widthPx, + candidateCrop.widthPx, + ); + const heightPx = Math.max( + baselineCrop.heightPx, + candidateCrop.heightPx, + ); + const [baselinePadded, candidatePadded] = await Promise.all([ + padRaster(baselineCrop, widthPx, heightPx), + padRaster(candidateCrop, widthPx, heightPx), + ]); + return comparePageRasters( + baselinePadded, + candidatePadded, + thresholds.pixelDifferenceThreshold, + ); + })(); + } catch (error) { + throw new Error( + `第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行栅格比较失败:` + + `基线 ${baselineCrop.widthPx}x${baselineCrop.heightPx}/${baselineCrop.png.byteLength},` + + `候选 ${candidateCrop.widthPx}x${candidateCrop.heightPx}/${candidateCrop.png.byteLength};` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } const lineIssues = localRasterIssues( paragraph.expectation.index, lineIndex, paragraph.expectation.blockKind, + paragraph.expectation.hasInlineCode === true, result.metrics, thresholds, textReflow, @@ -376,6 +629,139 @@ export async function comparePdfSemanticBlockVisuals( issues: lineIssues, }); } + } else { + const reflowPairs = pairReflowedTextLines( + paragraph.baseline.lineTexts, + paragraph.candidate.lineTexts, + ); + const sliceByCharacterOverlap = + paragraph.baseline.visualLines.length === + paragraph.candidate.visualLines.length; + if (reflowPairs.length === 0) { + issues.push({ + code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE", + severity: "failure", + message: `第 ${paragraph.expectation.index + 1} 个语义块无法按文本区间配对跨引擎视觉行`, + details: { paragraphIndex: paragraph.expectation.index }, + }); + } + for (let pairIndex = 0; pairIndex < reflowPairs.length; pairIndex += 1) { + const pair = reflowPairs[pairIndex]!; + const baselineLine = + paragraph.baseline.visualLines[pair.baselineLineIndex]; + const candidateLine = + paragraph.candidate.visualLines[pair.candidateLineIndex]; + const baselinePage = baselineLine + ? baseline.pages[baselineLine.pageNumber - 1] + : undefined; + const candidatePage = candidateLine + ? candidate.pages[candidateLine.pageNumber - 1] + : undefined; + if ( + !baselineLine || + !candidateLine || + !baselinePage?.raster || + !candidatePage?.raster + ) { + const unavailable: VisualDiffIssue = { + code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE", + severity: "failure", + message: `第 ${paragraph.expectation.index + 1} 个语义块的跨引擎重排行缺少栅格`, + details: { + paragraphIndex: paragraph.expectation.index, + lineIndex: pairIndex, + }, + }; + issues.push(unavailable); + lines.push({ + lineIndex: pairIndex, + baselinePageNumber: baselineLine?.pageNumber ?? -1, + candidatePageNumber: candidateLine?.pageNumber ?? -1, + issues: [unavailable], + }); + continue; + } + const baselineBounds = sliceByCharacterOverlap + ? sliceTextLineBounds( + baselineLine.bounds, + paragraph.baseline.lineTexts[pair.baselineLineIndex] ?? "", + pair.baselineStart, + pair.baselineEnd, + ) + : baselineLine.bounds; + const candidateBounds = sliceByCharacterOverlap + ? sliceTextLineBounds( + candidateLine.bounds, + paragraph.candidate.lineTexts[pair.candidateLineIndex] ?? "", + pair.candidateStart, + pair.candidateEnd, + ) + : candidateLine.bounds; + const [baselineCrop, candidateCrop] = await Promise.all([ + cropRaster(baselinePage.raster, baselineBounds), + cropRaster(candidatePage.raster, candidateBounds), + ]); + assertPngRaster( + baselineCrop, + `第 ${paragraph.expectation.index + 1} 个语义块重排行基线裁剪`, + ); + assertPngRaster( + candidateCrop, + `第 ${paragraph.expectation.index + 1} 个语义块重排行候选裁剪`, + ); + const widthPx = Math.max( + baselineCrop.widthPx, + candidateCrop.widthPx, + ); + const heightPx = Math.max( + baselineCrop.heightPx, + candidateCrop.heightPx, + ); + const [baselinePadded, candidatePadded] = await Promise.all([ + padRaster(baselineCrop, widthPx, heightPx), + padRaster(candidateCrop, widthPx, heightPx), + ]); + const result = await comparePageRasters( + baselinePadded, + candidatePadded, + thresholds.pixelDifferenceThreshold, + ); + const reflowIssue: VisualDiffIssue = { + code: "PARAGRAPH_LINE_BREAK_MISMATCH", + severity: "warning", + message: `第 ${paragraph.expectation.index + 1} 个语义块发生允许的跨引擎流式换行`, + details: { + paragraphIndex: paragraph.expectation.index, + lineIndex: pairIndex, + baselineLineIndex: pair.baselineLineIndex, + candidateLineIndex: pair.candidateLineIndex, + textReflow: true, + }, + }; + const lineIssues = [ + reflowIssue, + ...localRasterIssues( + paragraph.expectation.index, + pairIndex, + paragraph.expectation.blockKind, + paragraph.expectation.hasInlineCode === true, + result.metrics, + thresholds, + true, + ), + ]; + issues.push(...lineIssues); + lines.push({ + lineIndex: pairIndex, + baselinePageNumber: baselineLine.pageNumber, + candidatePageNumber: candidateLine.pageNumber, + metrics: result.metrics, + ...(lineIssues.some((issue) => issue.severity === "failure") + ? { artifacts: result.artifacts } + : {}), + issues: lineIssues, + }); + } } comparisons.push({ expectation: paragraph.expectation, diff --git a/packages/document-visual-diff/src/text.ts b/packages/document-visual-diff/src/text.ts index d243256..b76c92a 100644 --- a/packages/document-visual-diff/src/text.ts +++ b/packages/document-visual-diff/src/text.ts @@ -7,7 +7,7 @@ import type { VisualPageSemanticExpectation, } from "./types.js"; -const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu; +const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu; const CJK_RADICAL_VARIANTS = new Map([ ["⺠", "民"], ["⻅", "见"], @@ -16,6 +16,10 @@ const CJK_RADICAL_VARIANTS = new Map([ ["⻔", "门"], ["⻚", "页"], ["⻛", "风"], + ["⻝", "食"], + ["⻣", "骨"], + ["⻋", "车"], + ["⻬", "齐"], ]); const PAGE_NUMBER_PATTERNS = [ /^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u, @@ -30,6 +34,16 @@ export function normalizePdfText(value: string): string { .replace(/[\u2e80-\u2eff]/gu, (character) => CJK_RADICAL_VARIANTS.get(character) ?? character ) + // Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号; + // 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。 + .replace(/[“”]/gu, '"') + .replace(/[‘’]/gu, "'") + // Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar; + // 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。 + .replace(/―/gu, "—") + // Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个 + // ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。 + .replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "–") .replace(ZERO_WIDTH_AND_CONTROL, "") .replace(/\u00a0/gu, " ") .replace(/[ \t]+/gu, " ") @@ -133,6 +147,63 @@ function median(values: readonly number[]): number { return ((sorted[middle - 1] ?? value) + value) / 2; } +function mergeInlineScriptGroups( + sourceGroups: readonly PdfTextItemSnapshot[][], + typicalHeight: number, +): PdfTextItemSnapshot[][] { + const groups = sourceGroups.map((group) => [...group]); + for (let index = 0; index < groups.length; index += 1) { + const group = groups[index]; + if (!group || group.length === 0) { + continue; + } + const groupHeight = median(group.map((item) => item.bounds.height)); + if (groupHeight > typicalHeight * 0.9) { + continue; + } + const groupBounds = unionBounds(group); + for (const adjacentIndex of [index - 1, index + 1]) { + const adjacent = groups[adjacentIndex]; + if (!adjacent || adjacent.length === 0) { + continue; + } + const adjacentHeight = median( + adjacent.map((item) => item.bounds.height), + ); + if (adjacentHeight < groupHeight / 0.9) { + continue; + } + const adjacentBounds = unionBounds(adjacent); + const verticalOverlap = Math.min( + groupBounds.y + groupBounds.height, + adjacentBounds.y + adjacentBounds.height, + ) - Math.max(groupBounds.y, adjacentBounds.y); + const horizontallyContained = + groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 && + groupBounds.x + groupBounds.width <= + adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5; + // Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文 + // 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文 + // 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号 + // 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。 + const adjacentRight = adjacentBounds.x + adjacentBounds.width; + const touchesAdjacentRight = + groupBounds.x >= adjacentBounds.x && + Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5; + if ( + verticalOverlap <= 0 || + (!horizontallyContained && !touchesAdjacentRight) + ) { + continue; + } + adjacent.push(...group); + groups[index] = []; + break; + } + } + return groups.filter((group) => group.length > 0); +} + function joinLineItems(items: readonly PdfTextItemSnapshot[]): string { let result = ""; let previous: PdfTextItemSnapshot | undefined; @@ -181,6 +252,9 @@ export function aggregatePdfTextLines( sourceItems: readonly PdfTextItemSnapshot[], pageHeightPt: number, ): PdfTextLineSnapshot[] { + const sourceOrder = new Map( + sourceItems.map((item, index) => [item, index] as const), + ); const items = sourceItems .filter((item) => item.normalizedText.length > 0) .sort( @@ -188,11 +262,14 @@ export function aggregatePdfTextLines( 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[][] = []; + // Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt + // 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。 + // 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。 + const baselineTolerance = Math.max(2, typicalHeight * 0.2); + const initialGroups: PdfTextItemSnapshot[][] = []; for (const item of items) { - const lastGroup = groups.at(-1); + const lastGroup = initialGroups.at(-1); const lastBaseline = lastGroup ? median(lastGroup.map((entry) => entry.baselineY)) : undefined; @@ -203,13 +280,27 @@ export function aggregatePdfTextLines( ) { lastGroup.push(item); } else { - groups.push([item]); + initialGroups.push([item]); } } + const groups = mergeInlineScriptGroups(initialGroups, typicalHeight); + return groups.map((group) => { const sortedItems = [...group].sort( - (left, right) => left.bounds.x - right.bounds.x, + (left, right) => { + const leftRight = left.bounds.x + left.bounds.width; + const rightRight = right.bounds.x + right.bounds.width; + const horizontallyOverlapping = + left.bounds.x < rightRight && right.bounds.x < leftRight; + // PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与 + // 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到 + // 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序, + // 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。 + return horizontallyOverlapping + ? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0) + : left.bounds.x - right.bounds.x; + }, ); const bounds = unionBounds(sortedItems); const normalizedText = joinLineItems(sortedItems); diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts index 3706d23..8592f73 100644 --- a/packages/document-visual-diff/src/types.ts +++ b/packages/document-visual-diff/src/types.ts @@ -71,6 +71,12 @@ export type EditableParagraphBlockKind = export interface EditableParagraphExpectation { index: number; text: string; + mathCharacterIndexes?: number[]; + hardBreakSegments?: string[]; + hasInlineCode?: boolean; + tableGroupId?: string; + tableRowIndex?: number; + tableColumnIndex?: number; styleId?: string; role: EditableParagraphRole; blockKind?: EditableParagraphBlockKind; @@ -144,6 +150,7 @@ export interface PdfPageRaster { dpi: number; sha256: string; png: Uint8Array; + rgba?: Uint8ClampedArray; } export interface PdfPageSnapshot { @@ -287,9 +294,16 @@ export interface PdfRasterDiffMetrics { meanAbsoluteError: number; changedPixelRatio: number; inkIou: number; + foregroundInkIou: number; edgeIou: number; backgroundColorDelta: number; foregroundColorDelta: number; + baselineForegroundChroma?: number; + candidateForegroundChroma?: number; + foregroundLuminanceDelta?: number; + dominantForegroundColorDelta?: number; + chromaticForegroundColorDelta?: number; + chromaticPaletteDelta?: number; } export interface ComparePageRasterOptions { diff --git a/packages/document-visual-diff/tests/adapters.test.ts b/packages/document-visual-diff/tests/adapters.test.ts index 4d3cdcd..22b4319 100644 --- a/packages/document-visual-diff/tests/adapters.test.ts +++ b/packages/document-visual-diff/tests/adapters.test.ts @@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => { await rm(directory, { recursive: true, force: true }); } }); + + it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => { + const directory = await mkdtemp( + join(tmpdir(), "visual-diff-office-retry-test-"), + ); + const docxPath = join(directory, "fixture.docx"); + await writeFile(docxPath, "fixture"); + let attempts = 0; + const backend: OfficeAutomationBackend = { + probe: async () => true, + exportPdf: async (options) => { + attempts += 1; + if (attempts === 1) { + await writeFile(options.outputPath, "partial"); + throw new Error("Office 未生成有效 PDF"); + } + const pdf = createMinimalPdf("retry"); + await writeFile(options.outputPath, pdf); + return { bytes: pdf.byteLength, pageCount: 1 }; + }, + }; + try { + const result = await createWordPdfAdapter({ backend }).generate({ + docxPath, + }); + expect(result.pageCount).toBe(1); + expect(attempts).toBe(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); describe("PDF 适配器视觉编排", () => { diff --git a/packages/document-visual-diff/tests/paragraph-layout.test.ts b/packages/document-visual-diff/tests/paragraph-layout.test.ts index b28f0b1..eaa2104 100644 --- a/packages/document-visual-diff/tests/paragraph-layout.test.ts +++ b/packages/document-visual-diff/tests/paragraph-layout.test.ts @@ -23,6 +23,23 @@ function line( }; } +function itemLine( + text: string, + y: number, + width = 120, + x = 72, +): PdfTextLineSnapshot { + const result = line(text, y, width, x); + result.items = [{ + text, + normalizedText: text, + bounds: { ...result.bounds }, + baselineY: y, + hasEol: true, + }]; + return result; +} + function snapshot( label: string, pages: PdfTextLineSnapshot[][], @@ -59,6 +76,319 @@ function expectation( } describe("PDF 内容感知段落版式门禁", () => { + it("只在 OMML 数学字符位置容忍 WPS 文本层字母占位符", () => { + const expected = "减因子e−λΔt的动态权重"; + const baseline = snapshot("baseline", [[line(expected, 100)]]); + const candidate = snapshot( + "candidate", + [[line("减因子A−AΔA的动态权重", 100)]], + ); + const result = comparePdfEditableParagraphLayouts( + baseline, + candidate, + [expectation(expected, { + mathCharacterIndexes: [3, 4, 5, 6, 7], + })], + )[0]; + + expect(result?.baseline.matched).toBe(true); + expect(result?.candidate.matched).toBe(true); + }); + + it("数学公式被 PDF 文本层提前抽取时仍能映射公式后的正文", () => { + const expected = "衰减因子e−λΔt的动态权重调整"; + const baseline = snapshot("baseline", [[ + line("−λΔt", 96), + line("衰减因子e", 100), + line("的动态权重调整", 116), + ]]); + const candidate = snapshot("candidate", [[ + line("−λΔt", 96), + line("衰减因子e", 100), + line("的动态权重调整", 116), + ]]); + const result = comparePdfEditableParagraphLayouts( + baseline, + candidate, + [expectation(expected, { + mathCharacterIndexes: [4, 5, 6, 7, 8], + })], + )[0]; + + expect(result?.baseline.matched).toBe(true); + expect(result?.candidate.matched).toBe(true); + expect(result?.baseline.matchedCharacterCount).toBe( + Array.from(expected).length, + ); + }); + + it("表格硬换行跨视觉行时允许相邻单元格文本交错", () => { + const visualLines = [ + line("标签第一行旁甲", 200), + line("第二行旁乙", 220), + line("第三行", 240), + line("下一行", 260), + ]; + const paragraphs = [ + expectation("标签", { index: 0, blockKind: "table-cell" }), + expectation("第一行第二行第三行", { + index: 1, + blockKind: "table-cell", + hardBreakSegments: ["第一行", "第二行", "第三行"], + }), + expectation("旁甲旁乙", { + index: 2, + blockKind: "table-cell", + hardBreakSegments: ["旁甲", "旁乙"], + }), + expectation("下一行", { index: 3, blockKind: "table-cell" }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results.every((result) => result.baseline.matched)).toBe(true); + expect(results.every((result) => result.candidate.matched)).toBe(true); + expect(results.every((result) => result.status === "passed")).toBe(true); + expect(results[1]?.baseline.lineTexts).toEqual([ + "第一行", + "第二行", + "第三行", + ]); + }); + + it("多行表格按单元格语义匹配而不依赖 PDF 的视觉行顺序", () => { + const visualLines = [ + line("甲列丙列", 200), + line("乙列丁列", 220), + line("表格后正文", 260), + ]; + const paragraphs = [ + expectation("甲列乙列", { index: 0, blockKind: "table-cell" }), + expectation("丙列丁列", { index: 1, blockKind: "table-cell" }), + expectation("表格后正文", { index: 2 }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results.every((result) => result.baseline.matched)).toBe(true); + expect(results.every((result) => result.candidate.matched)).toBe(true); + }); + + it("同一表格中的重复短文本只消费各自的字符范围", () => { + const visualLines = [ + line("表名一DIM已实现", 200), + line("表名二DIM已实现", 220), + line("表格后正文", 260), + ]; + const table = { blockKind: "table-cell" as const, tableGroupId: "table-0" }; + const paragraphs = [ + expectation("表名一", { index: 0, ...table }), + expectation("DIM", { index: 1, ...table }), + expectation("已实现", { index: 2, ...table }), + expectation("表名二", { index: 3, ...table }), + expectation("DIM", { index: 4, ...table }), + expectation("已实现", { index: 5, ...table }), + expectation("表格后正文", { index: 6 }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results.every((result) => result.baseline.matched)).toBe(true); + expect(results[1]?.baseline.firstLineBaselineYPt).toBe(200); + expect(results[4]?.baseline.firstLineBaselineYPt).toBe(220); + expect(results[6]?.baseline.firstLineBaselineYPt).toBe(260); + }); + + it("带稳定行列坐标的表格不会把重复短文本匹配到后续章节", () => { + const tableLine = ( + cells: readonly string[], + y: number, + xPositions: readonly number[], + ) => { + const result = line(cells.join(""), y, 640, xPositions[0]); + result.items = cells.map((text, index) => ({ + text, + normalizedText: text, + bounds: { + x: xPositions[index]!, + y: y - 10, + width: Math.max(12, Array.from(text).length * 12), + height: 12, + }, + baselineY: y, + hasEol: index === cells.length - 1, + })); + return result; + }; + const columns = [72, 150, 420, 500]; + const visualLines = [ + line("按模块统计", 160), + tableLine(["章节", "模块", "DIM", "合计"], 200, columns), + tableLine(["一", "维度层", "13", "13"], 220, columns), + tableLine(["二", "溯源总览", "0", "4"], 240, columns), + line("后续章节包含DIM以及数字0和4", 600), + ]; + const table = { + blockKind: "table-cell" as const, + tableGroupId: "table-0", + }; + const paragraphs = [ + expectation("按模块统计", { index: 0, blockKind: "heading" }), + ...[ + ["章节", 0, 0], ["模块", 0, 1], ["DIM", 0, 2], ["合计", 0, 3], + ["一", 1, 0], ["维度层", 1, 1], ["13", 1, 2], ["13", 1, 3], + ["二", 2, 0], ["溯源总览", 2, 1], ["0", 2, 2], ["4", 2, 3], + ].map(([text, row, column], index) => + expectation(text as string, { + index: index + 1, + ...table, + tableRowIndex: row as number, + tableColumnIndex: column as number, + }) + ), + expectation("后续章节包含DIM以及数字0和4", { + index: 13, + blockKind: "paragraph", + }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results.every((result) => result.baseline.matched)).toBe(true); + expect(results.slice(1, 13).map((result) => + result.baseline.firstLineBaselineYPt + )).toEqual([ + 200, 200, 200, 200, + 220, 220, 220, 220, + 240, 240, 240, 240, + ]); + expect(results[13]?.baseline.firstLineBaselineYPt).toBe(600); + }); + + it("前一表长单元格不完整时不会污染后一表的稳定行列定位", () => { + const visualLines = [ + line("第一表标题", 100), + line("层级说明", 140), + line("ODS短说明", 160), + line("第二表标题", 220), + line("章节模块", 260), + line("一溯源总览", 280), + line("文档后部再次出现章节模块一溯源总览", 700), + ]; + const paragraphs = [ + expectation("第一表标题", { index: 0, blockKind: "heading" }), + expectation("层级", { + index: 1, + blockKind: "table-header", + tableGroupId: "table-0", + tableRowIndex: 0, + tableColumnIndex: 0, + }), + expectation("说明", { + index: 2, + blockKind: "table-header", + tableGroupId: "table-0", + tableRowIndex: 0, + tableColumnIndex: 1, + }), + expectation("ODS", { + index: 3, + blockKind: "table-cell", + tableGroupId: "table-0", + tableRowIndex: 1, + tableColumnIndex: 0, + }), + expectation("这是一段在PDF中被截断的长说明", { + index: 4, + blockKind: "table-cell", + tableGroupId: "table-0", + tableRowIndex: 1, + tableColumnIndex: 1, + }), + expectation("第二表标题", { index: 5, blockKind: "heading" }), + expectation("章节", { + index: 6, + blockKind: "table-header", + tableGroupId: "table-1", + tableRowIndex: 0, + tableColumnIndex: 0, + }), + expectation("模块", { + index: 7, + blockKind: "table-header", + tableGroupId: "table-1", + tableRowIndex: 0, + tableColumnIndex: 1, + }), + expectation("一", { + index: 8, + blockKind: "table-cell", + tableGroupId: "table-1", + tableRowIndex: 1, + tableColumnIndex: 0, + }), + expectation("溯源总览", { + index: 9, + blockKind: "table-cell", + tableGroupId: "table-1", + tableRowIndex: 1, + tableColumnIndex: 1, + }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results[4]?.baseline.matched).toBe(false); + expect(results[5]?.baseline.firstLineBaselineYPt).toBe(220); + expect(results.slice(6).map((result) => + result.baseline.firstLineBaselineYPt + )).toEqual([260, 260, 280, 280]); + }); + + it("表格结束后同名标题不会复用已消费的单元格文本", () => { + const visualLines = [ + line("食安监控状态正常", 200), + line("食安监控", 260), + ]; + const paragraphs = [ + expectation("食安监控", { + index: 0, + blockKind: "table-cell", + tableGroupId: "table-0", + }), + expectation("状态正常", { + index: 1, + blockKind: "table-cell", + tableGroupId: "table-0", + }), + expectation("食安监控", { index: 2, blockKind: "heading" }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results.every((result) => result.baseline.matched)).toBe(true); + expect(results[0]?.baseline.firstLineBaselineYPt).toBe(200); + expect(results[2]?.baseline.firstLineBaselineYPt).toBe(260); + }); + it("同一物理行中的表格单元格分别保留自己的视觉边界", () => { const tableLine = line("建设内容 主要能力", 220, 320, 72); tableLine.items = [ @@ -97,6 +427,1586 @@ describe("PDF 内容感知段落版式门禁", () => { ); }); + it("表格换行匹配优先保留目标单元格的最长连续前缀", () => { + const firstLine = line( + "B超报告原文描述 B超报告中是否有对应病灶 若B超无相关描述", + 220, + 480, + 72, + ); + firstLine.items = [ + { + text: "B超报告原文描述", + normalizedText: "B超报告原文描述", + bounds: { x: 120, y: 210, width: 96, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "B超报告中是否有对应病灶", + normalizedText: "B超报告中是否有对应病灶", + bounds: { x: 280, y: 210, width: 144, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "若B超无相关描述", + normalizedText: "若B超无相关描述", + bounds: { x: 450, y: 210, width: 96, height: 12 }, + baselineY: 220, + hasEol: true, + }, + ]; + const secondLine = line("描述", 238, 24, 120); + secondLine.items = [{ + text: "描述", + normalizedText: "描述", + bounds: { x: 120, y: 228, width: 24, height: 12 }, + baselineY: 238, + hasEol: true, + }]; + const thirdLine = line("描述", 256, 24, 280); + thirdLine.items = [{ + text: "描述", + normalizedText: "描述", + bounds: { x: 280, y: 246, width: 24, height: 12 }, + baselineY: 256, + hasEol: true, + }]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstLine, secondLine, thirdLine]]), + snapshot("candidate", [[firstLine, secondLine, thirdLine]]), + [expectation("B超报告中是否有对应病灶描述", { + blockKind: "table-cell", + })], + ); + + expect(result?.baseline.visualLines.map((entry) => entry.bounds.x)).toEqual([ + 280, + 280, + ]); + expect(result?.baseline.visualLines[0]?.bounds.width).toBe(144); + }); + + it("表格多列共享换行后缀时按首行列位置选择续行", () => { + const firstLine = line( + "高血压预测模型目 冠心病预测模型目", + 220, + 360, + 72, + ); + firstLine.items = [ + { + text: "高血压预测模型目", + normalizedText: "高血压预测模型目", + bounds: { x: 180, y: 210, width: 96, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "冠心病预测模型目", + normalizedText: "冠心病预测模型目", + bounds: { x: 300, y: 210, width: 96, height: 12 }, + baselineY: 220, + hasEol: true, + }, + ]; + const secondLine = line("标值 标值", 238, 144, 180); + secondLine.items = [ + { + text: "标值", + normalizedText: "标值", + bounds: { x: 180, y: 228, width: 24, height: 12 }, + baselineY: 238, + hasEol: false, + }, + { + text: "标值", + normalizedText: "标值", + bounds: { x: 300, y: 228, width: 24, height: 12 }, + baselineY: 238, + hasEol: true, + }, + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstLine, secondLine]]), + snapshot("candidate", [[firstLine, secondLine]]), + [ + expectation("高血压预测模型目标值", { + index: 0, + blockKind: "table-header", + }), + expectation("冠心病预测模型目标值", { + index: 1, + blockKind: "table-header", + }), + ], + ); + + expect(results[0]?.baseline.visualLines[1]?.bounds.x).toBe(180); + expect(results[1]?.baseline.visualLines[1]?.bounds.x).toBe(300); + }); + + it("表格长首行与短续行使用左边界保持在同一单元格", () => { + const firstLine = line("dim_snd_algo_st", 220, 160, 120); + firstLine.items = [{ + text: "dim_snd_algo_st", + normalizedText: "dim_snd_algo_st", + bounds: { x: 120, y: 210, width: 160, height: 12 }, + baselineY: 220, + hasEol: true, + }]; + const continuationLine = line("andard andard", 238, 140, 120); + continuationLine.items = [ + { + text: "andard", + normalizedText: "andard", + bounds: { x: 120, y: 228, width: 60, height: 12 }, + baselineY: 238, + hasEol: false, + }, + { + text: "andard", + normalizedText: "andard", + bounds: { x: 200, y: 228, width: 60, height: 12 }, + baselineY: 238, + hasEol: true, + }, + ]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstLine, continuationLine]]), + snapshot("candidate", [[firstLine, continuationLine]]), + [expectation("dim_snd_algo_standard", { blockKind: "table-cell" })], + ); + + expect(result?.baseline.lineTexts).toEqual(["dim_snd_algo_st", "andard"]); + expect(result?.baseline.visualLines[1]?.bounds.x).toBe(120); + }); + + it("同一物理行中被宽间隙拆开的单元格文本仍在目标列内续接", () => { + const header = line("维度说明 目标列", 200, 240, 100); + header.items = [ + { + text: "维度说明", + normalizedText: "维度说明", + bounds: { x: 100, y: 190, width: 48, height: 12 }, + baselineY: 200, + hasEol: false, + }, + { + text: "目标列", + normalizedText: "目标列", + bounds: { x: 260, y: 190, width: 36, height: 12 }, + baselineY: 200, + hasEol: true, + }, + ]; + const value = line("DIM 维度层 DIM 邻列", 220, 260, 100); + value.items = [ + { + text: "DIM", + normalizedText: "DIM", + bounds: { x: 100, y: 210, width: 24, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "维度层", + normalizedText: "维度层", + bounds: { x: 136, y: 210, width: 36, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "DIM", + normalizedText: "DIM", + bounds: { x: 260, y: 210, width: 24, height: 12 }, + baselineY: 220, + hasEol: false, + }, + { + text: "邻列", + normalizedText: "邻列", + bounds: { x: 296, y: 210, width: 24, height: 12 }, + baselineY: 220, + hasEol: true, + }, + ]; + const table = { tableGroupId: "table-split-items" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, value]]), + snapshot("candidate", [[header, value]]), + [ + expectation("维度说明", { + index: 0, + blockKind: "table-header", + ...table, + tableRowIndex: 0, + tableColumnIndex: 0, + }), + expectation("目标列", { + index: 1, + blockKind: "table-header", + ...table, + tableRowIndex: 0, + tableColumnIndex: 1, + }), + expectation("DIM维度层", { + index: 2, + blockKind: "table-cell", + ...table, + tableRowIndex: 1, + tableColumnIndex: 0, + }), + expectation("DIM邻列", { + index: 3, + blockKind: "table-cell", + ...table, + tableRowIndex: 1, + tableColumnIndex: 1, + }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, + true, + true, + true, + ]); + expect(results[2]?.baseline.visualLines[0]?.bounds.x).toBe(100); + expect(results[2]?.baseline.visualLines[0]?.bounds.width).toBe(72); + expect(results[3]?.baseline.visualLines[0]?.bounds.x).toBe(260); + }); + + it("相邻列共享前缀时先按列锚点匹配而不是跨列选择更长文本", () => { + const makeLine = ( + entries: readonly { text: string; x: number; width: number }[], + baselineY: number, + ) => { + const value = line( + entries.map((entry) => entry.text).join(" "), + baselineY, + 360, + 120, + ); + value.items = entries.map((entry, index) => ({ + text: entry.text, + normalizedText: entry.text, + bounds: { + x: entry.x, + y: baselineY - 10, + width: entry.width, + height: 12, + }, + baselineY, + hasEol: index === entries.length - 1, + })); + return value; + }; + const lines = [ + makeLine([ + { text: "资产名", x: 190, width: 36 }, + { text: "设计目标", x: 300, width: 48 }, + ], 200), + makeLine([ + { text: "算法标准", x: 180, width: 48 }, + { text: "算法标准统一维度", x: 300, width: 96 }, + ], 220), + makeLine([{ text: "统一维度表", x: 180, width: 60 }], 238), + ]; + const table = { tableGroupId: "table-shared-prefix" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [lines]), + snapshot("candidate", [lines]), + [ + expectation("资产名", { + index: 0, + blockKind: "table-header", + ...table, + tableRowIndex: 0, + tableColumnIndex: 0, + }), + expectation("设计目标", { + index: 1, + blockKind: "table-header", + ...table, + tableRowIndex: 0, + tableColumnIndex: 1, + }), + expectation("算法标准统一维度表", { + index: 2, + blockKind: "table-cell", + ...table, + tableRowIndex: 1, + tableColumnIndex: 0, + }), + expectation("算法标准统一维度", { + index: 3, + blockKind: "table-cell", + ...table, + tableRowIndex: 1, + tableColumnIndex: 1, + }), + ], + ); + + expect(results[2]?.baseline.matched).toBe(true); + expect(results[2]?.baseline.visualLines.map((entry) => entry.bounds.x)).toEqual([ + 180, + 180, + ]); + expect(results[3]?.baseline.visualLines[0]?.bounds.x).toBe(300); + }); + + it("首列表头与相邻列共享更长前缀时按最左列开始匹配", () => { + const first = line("meal meal_", 200, 140, 80); + first.items = [ + { + text: "meal", + normalizedText: "meal", + bounds: { x: 80, y: 190, width: 32, height: 12 }, + baselineY: 200, + hasEol: false, + }, + { + text: "meal_", + normalizedText: "meal_", + bounds: { x: 160, y: 190, width: 40, height: 12 }, + baselineY: 200, + hasEol: true, + }, + ]; + const second = line("_typ name", 218, 120, 80); + second.items = [ + { + text: "_typ", + normalizedText: "_typ", + bounds: { x: 80, y: 208, width: 32, height: 12 }, + baselineY: 218, + hasEol: false, + }, + { + text: "name", + normalizedText: "name", + bounds: { x: 160, y: 208, width: 32, height: 12 }, + baselineY: 218, + hasEol: true, + }, + ]; + const third = line("e _cn", 236, 80, 80); + third.items = [ + { + text: "e", + normalizedText: "e", + bounds: { x: 80, y: 226, width: 8, height: 12 }, + baselineY: 236, + hasEol: false, + }, + { + text: "_cn", + normalizedText: "_cn", + bounds: { x: 160, y: 226, width: 24, height: 12 }, + baselineY: 236, + hasEol: true, + }, + ]; + const table = { tableGroupId: "table-header-prefix", tableRowIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[first, second, third]]), + snapshot("candidate", [[first, second, third]]), + [ + expectation("meal_type", { + index: 0, + blockKind: "table-header", + ...table, + tableColumnIndex: 0, + }), + expectation("meal_name_cn", { + index: 1, + blockKind: "table-header", + ...table, + tableColumnIndex: 1, + }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, + true, + ]); + expect(results[0]?.baseline.visualLines[0]?.bounds.x).toBe(80); + }); + + it("右对齐表头续行按文本中心保持在同一列", () => { + const first = line("未实现(设计", 200, 80, 400); + first.items = [{ + text: "未实现(设计", + normalizedText: "未实现(设计", + bounds: { x: 400, y: 190, width: 80, height: 12 }, + baselineY: 200, + hasEol: true, + }]; + const second = line("态)", 218, 20, 450); + second.items = [{ + text: "态)", + normalizedText: "态)", + bounds: { x: 450, y: 208, width: 20, height: 12 }, + baselineY: 218, + hasEol: true, + }]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[first, second]]), + snapshot("candidate", [[first, second]]), + [expectation("未实现(设计态)", { + blockKind: "table-header", + tableGroupId: "table-right-header", + tableRowIndex: 0, + tableColumnIndex: 0, + })], + ); + + expect(result?.baseline.matched).toBe(true); + expect(result?.baseline.lineTexts).toEqual(["未实现(设计", "态)"]); + }); + + it("纵向表头续字优先选择后续视觉行中更靠近本列中心的字符", () => { + const first = line("DW D", 100, 48, 100); + first.items = [ + { + text: "DW", + normalizedText: "DW", + bounds: { x: 100, y: 90, width: 20, height: 12 }, + baselineY: 100, + hasEol: false, + }, + { + text: "D", + normalizedText: "D", + bounds: { x: 134, y: 90, width: 10, height: 12 }, + baselineY: 100, + hasEol: true, + }, + ]; + const interleaved = line("邻列", 109, 20, 150); + const continuation = line("D", 118, 10, 104); + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[first, interleaved, continuation]]), + snapshot("candidate", [[first, interleaved, continuation]]), + [expectation("DWD", { + blockKind: "table-header", + tableGroupId: "table-vertical-header", + tableRowIndex: 0, + tableColumnIndex: 0, + })], + ); + + expect(result?.baseline.matched).toBe(true); + expect(result?.baseline.lineTexts).toEqual(["DW", "D"]); + expect(result?.baseline.visualLines[1]?.bounds.x).toBe(104); + }); + + it("跨页重复表头与续行同列时优先选择更长的连续匹配", () => { + const firstLine = line("邻甲", 780, 24, 440); + firstLine.items = [{ + text: "邻甲", + normalizedText: "邻甲", + bounds: { x: 440, y: 770, width: 24, height: 12 }, + baselineY: 780, + hasEol: true, + }]; + const repeatedHeader = line("邻接单元格", 54, 60, 440); + repeatedHeader.items = [{ + text: "邻接单元格", + normalizedText: "邻接单元格", + bounds: { x: 440, y: 44, width: 60, height: 12 }, + baselineY: 54, + hasEol: true, + }]; + const continuation = line("邻乙", 86, 24, 440); + continuation.items = [{ + text: "邻乙", + normalizedText: "邻乙", + bounds: { x: 440, y: 76, width: 24, height: 12 }, + baselineY: 86, + hasEol: true, + }]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstLine], [repeatedHeader, continuation]]), + snapshot("candidate", [[firstLine], [repeatedHeader, continuation]]), + [expectation("邻甲邻乙", { + blockKind: "table-cell", + hardBreakSegments: ["邻甲", "邻乙"], + })], + ); + + expect(result?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]); + expect(result?.baseline.visualLines).toHaveLength(2); + expect(result?.baseline.visualLines[1]?.bounds).toEqual( + continuation.items[0]?.bounds, + ); + }); + + it("稳定行列坐标下跨页硬换行避开重复表头的同首字前缀", () => { + const header = line("门禁 内容 邻接单元格", 740, 420, 80); + header.items = [ + { text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 730, width: 24, height: 12 }, baselineY: 740, hasEol: false }, + { text: "内容", normalizedText: "内容", bounds: { x: 240, y: 730, width: 24, height: 12 }, baselineY: 740, hasEol: false }, + { text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 440, y: 730, width: 60, height: 12 }, baselineY: 740, hasEol: true }, + ]; + const row = line("行内代码 正文 邻甲", 780, 420, 80); + row.items = [ + { text: "行内代码", normalizedText: "行内代码", bounds: { x: 80, y: 770, width: 48, height: 12 }, baselineY: 780, hasEol: false }, + { text: "正文", normalizedText: "正文", bounds: { x: 240, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: false }, + { text: "邻甲", normalizedText: "邻甲", bounds: { x: 440, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: true }, + ]; + const repeatedHeader = line("门禁 内容 邻接单元格", 54, 420, 80); + repeatedHeader.items = [ + { text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false }, + { text: "内容", normalizedText: "内容", bounds: { x: 240, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false }, + { text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 440, y: 44, width: 60, height: 12 }, baselineY: 54, fontFamily: "EmbeddedSerif,Bold", hasEol: true }, + ]; + const continuation = line("续行 其他 邻乙", 87, 420, 80); + continuation.items = [ + { text: "续行", normalizedText: "续行", bounds: { x: 80, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: false }, + { text: "其他", normalizedText: "其他", bounds: { x: 240, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: false }, + { text: "邻乙", normalizedText: "邻乙", bounds: { x: 440, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: true }, + ]; + const group = { tableGroupId: "table-stable-cross-page-hard-break" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, row], [repeatedHeader, continuation]]), + snapshot("candidate", [[header, row], [repeatedHeader, continuation]]), + [ + expectation("门禁", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("邻接单元格", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("行内代码", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("邻甲邻乙", { + index: 5, + blockKind: "table-cell", + ...group, + tableRowIndex: 1, + tableColumnIndex: 2, + hardBreakSegments: ["邻甲", "邻乙"], + }), + ], + ); + + expect(results[5]?.baseline.matched).toBe(true); + expect(results[5]?.candidate.matched).toBe(true); + expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]); + expect(results[5]?.candidate.lineTexts).toEqual(["邻甲", "邻乙"]); + expect(results[5]?.candidate.visualLines[1]?.fontFamilies).not.toContain( + "EmbeddedSerif,Bold", + ); + }); + + it("跨页重复表头与正文续行等长时选择更精确的原列坐标", () => { + const firstLine = line("已实", 780, 22, 506); + firstLine.items = [{ + text: "已实", + normalizedText: "已实", + bounds: { x: 506, y: 770, width: 22, height: 12 }, + baselineY: 780, + hasEol: true, + }]; + const repeatedHeader = line("实现", 54, 22, 508); + repeatedHeader.items = [{ + text: "实现", + normalizedText: "实现", + bounds: { x: 508, y: 44, width: 22, height: 12 }, + baselineY: 54, + fontFamily: "EmbeddedSerif,Bold", + hasEol: true, + }]; + const continuation = line("现", 86, 11, 506); + continuation.items = [{ + text: "现", + normalizedText: "现", + bounds: { x: 506, y: 76, width: 11, height: 12 }, + baselineY: 86, + fontFamily: "EmbeddedSerif", + hasEol: true, + }]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstLine], [repeatedHeader, continuation]]), + snapshot("candidate", [[firstLine], [repeatedHeader, continuation]]), + [expectation("已实现", { blockKind: "table-cell" })], + ); + + expect(result?.baseline.matched).toBe(true); + expect(result?.baseline.lineTexts).toEqual(["已实", "现"]); + expect(result?.baseline.visualLines[1]?.bounds).toEqual( + continuation.items[0]?.bounds, + ); + expect(result?.baseline.visualLines[1]?.fontFamilies).toEqual([ + "EmbeddedSerif", + ]); + }); + + it("跨页表格后续列复用前列建立的正文续行基线而避开重复表头", () => { + const firstPage = line("终端 已实", 780, 180, 80); + firstPage.items = [ + { text: "终端", normalizedText: "终端", bounds: { x: 80, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: false }, + { text: "已实", normalizedText: "已实", bounds: { x: 220, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: true }, + ]; + const repeatedHeader = line("表名 实现状态", 54, 190, 80); + repeatedHeader.items = [ + { text: "表名", normalizedText: "表名", bounds: { x: 80, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false }, + { text: "实现状态", normalizedText: "实现状态", bounds: { x: 222, y: 44, width: 48, height: 12 }, baselineY: 54, fontFamily: "EmbeddedSerif,Bold", hasEol: true }, + ]; + const continuation = line("设备维表 现", 104, 180, 80); + continuation.items = [ + { text: "设备维表", normalizedText: "设备维表", bounds: { x: 80, y: 94, width: 48, height: 12 }, baselineY: 104, hasEol: false }, + { text: "现", normalizedText: "现", bounds: { x: 220, y: 94, width: 12, height: 12 }, baselineY: 104, fontFamily: "EmbeddedSerif", hasEol: true }, + ]; + const group = { tableGroupId: "table-cross-page-row-anchor", tableRowIndex: 1 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[firstPage], [repeatedHeader, continuation]]), + snapshot("candidate", [[firstPage], [repeatedHeader, continuation]]), + [ + expectation("终端设备维表", { index: 0, blockKind: "table-cell", ...group, tableColumnIndex: 0 }), + expectation("已实现", { index: 1, blockKind: "table-cell", ...group, tableColumnIndex: 1 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([true, true]); + expect(results[1]?.baseline.lineTexts).toEqual(["已实", "现"]); + expect(results[1]?.baseline.visualLines[1]?.bounds).toEqual( + continuation.items[1]?.bounds, + ); + expect(results[1]?.baseline.visualLines[1]?.fontFamilies).toEqual([ + "EmbeddedSerif", + ]); + }); + + it("表格行跨页时后续列只从行锚点所在物理页开始匹配", () => { + const previousPageValue = line("计算", 780, 24, 180); + const rowLabel = line("DWS", 100, 30, 80); + const currentPageValue = line("计算", 100, 24, 180); + const table = { + tableGroupId: "table-cross-page-row", + tableRowIndex: 4, + }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[previousPageValue], [rowLabel, currentPageValue]]), + snapshot("candidate", [[previousPageValue], [rowLabel, currentPageValue]]), + [ + expectation("DWS", { + index: 0, + blockKind: "table-cell", + ...table, + tableColumnIndex: 0, + }), + expectation("计算", { + index: 1, + blockKind: "table-cell", + ...table, + tableColumnIndex: 1, + }), + ], + ); + + expect(results[1]?.baseline.matched).toBe(true); + expect(results[1]?.baseline.visualLines[0]?.pageNumber).toBe(2); + }); + + it("表格首列在当前行已命中有效前缀时不会被稍后更长同名正文抢占", () => { + const header = line("层级", 740, 20, 80); + header.items = [{ + text: "层级", + normalizedText: "层级", + bounds: { x: 80, y: 730, width: 20, height: 12 }, + baselineY: 740, + hasEol: true, + }]; + const firstPart = line("DW", 760, 20, 80); + const interleaved = line("计算", 780, 24, 180); + const secondPart = line("S", 800, 10, 80); + const laterParagraph = line("DWS 后续正文", 820, 120, 80); + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, firstPart, interleaved, secondPart, laterParagraph]]), + snapshot("candidate", [[header, firstPart, interleaved, secondPart, laterParagraph]]), + [ + expectation("层级", { + index: 0, + blockKind: "table-header", + tableGroupId: "table-current-page-prefix", + tableRowIndex: 0, + tableColumnIndex: 0, + }), + expectation("DWS", { + index: 1, + blockKind: "table-cell", + tableGroupId: "table-current-page-prefix", + tableRowIndex: 1, + tableColumnIndex: 0, + }), + ], + ); + const result = results[1]; + + expect(result?.baseline.matched).toBe(true); + expect(result?.baseline.visualLines.map((entry) => entry.pageNumber)).toEqual([1, 1]); + expect(result?.baseline.lineTexts).toEqual(["DW", "S"]); + }); + + it("左对齐数据列可落在居中表头中心边界左侧但不得越过上一列右边界", () => { + const makeLine = ( + entries: readonly { text: string; x: number; width: number }[], + baselineY: number, + ) => { + const value = line(entries.map((entry) => entry.text).join(" "), baselineY, 340, 40); + value.items = entries.map((entry, index) => ({ + text: entry.text, + normalizedText: entry.text, + bounds: { x: entry.x, y: baselineY - 10, width: entry.width, height: 12 }, + baselineY, + hasEol: index === entries.length - 1, + })); + return value; + }; + const header = makeLine([ + { text: "章节", x: 46, width: 24 }, + { text: "模块", x: 192, width: 24 }, + { text: "DIM", x: 339, width: 26 }, + ], 100); + const body = makeLine([ + { text: "一", x: 45, width: 12 }, + { text: "DIM 维度层", x: 84, width: 64 }, + { text: "13", x: 353, width: 12 }, + ], 130); + const group = { tableGroupId: "table-centered-header-left-data" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, body]]), + snapshot("candidate", [[header, body]]), + [ + expectation("章节", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("模块", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("DIM", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("一", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("DIM 维度层", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("13", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, + true, + true, + true, + true, + true, + ]); + expect(results[4]?.baseline.visualLines[0]?.bounds.x).toBe(84); + }); + + it("同一表格行的后续列可在行锚点所在物理页的较晚视觉行开始", () => { + const visualLines = [ + line("字段名", 100, 24, 80), + line("其他列一", 120, 36, 110), + line("其他列二", 140, 36, 130), + line("较晚列值", 160, 48, 220), + ]; + const group = { tableGroupId: "table-late-column-start", tableRowIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + [ + expectation("字段名", { + index: 0, + blockKind: "table-header", + ...group, + tableColumnIndex: 0, + }), + expectation("较晚列值", { + index: 1, + blockKind: "table-header", + ...group, + tableColumnIndex: 1, + }), + ], + ); + + expect(results[1]?.baseline.matched).toBe(true); + expect(results[1]?.candidate.matched).toBe(true); + expect(results[1]?.baseline.visualLines[0]?.pageNumber).toBe(1); + }); + + it("极窄右对齐表头允许末尾短行相对长前缀发生中心偏移", () => { + const visualLines = [ + line("字段", 100, 24, 80), + line("typical_start_ho", 120, 100, 180), + line("ur", 140, 14, 266), + ]; + const group = { tableGroupId: "table-narrow-header", tableRowIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + [ + expectation("字段", { + index: 0, + blockKind: "table-header", + ...group, + tableColumnIndex: 0, + }), + expectation("typical_start_hour", { + index: 1, + blockKind: "table-header", + ...group, + tableColumnIndex: 1, + }), + ], + ); + + expect(results[1]?.baseline.matched).toBe(true); + expect(results[1]?.candidate.matched).toBe(true); + expect(results[1]?.baseline.lineTexts).toEqual(["typical_start_ho", "ur"]); + }); + + it("表格同一单元格的分散对齐字符可跨较大字间距完整匹配", () => { + const header = line("字段 设计目标 状态", 100, 440, 80); + header.items = [ + { text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "设计目标", normalizedText: "设计目标", bounds: { x: 240, y: 90, width: 48, height: 12 }, baselineY: 100, hasEol: false }, + { text: "状态", normalizedText: "状态", bounds: { x: 500, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const row = line("编号 用 户 营 养 档 案 已实现", 140, 440, 80); + row.items = [ + { text: "编号", normalizedText: "编号", bounds: { x: 80, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: false }, + ...Array.from("用户营养档案").map((text, index) => ({ + text, + normalizedText: text, + bounds: { x: 240 + index * 34, y: 130, width: 12, height: 12 }, + baselineY: 140, + hasEol: false, + })), + { text: "已实现", normalizedText: "已实现", bounds: { x: 500, y: 130, width: 36, height: 12 }, baselineY: 140, hasEol: true }, + ]; + const group = { tableGroupId: "table-distributed-cell" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, row]]), + snapshot("candidate", [[header, row]]), + [ + expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("设计目标", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("状态", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("编号", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("用户营养档案", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("已实现", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, true, true, + ]); + expect(results[4]?.baseline.lineTexts).toEqual(["用户营养档案"]); + }); + + it("同列重复短文本按当前表格行的纵向锚点选择", () => { + const header = line("字段 记录", 100, 220, 80); + header.items = [ + { text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "记录", normalizedText: "记录", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const rowLabel = line("更新时间", 200, 48, 80); + const rowValue = line("记录", 200, 24, 240); + const group = { tableGroupId: "table-row-anchor" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, rowLabel, rowValue]]), + snapshot("candidate", [[header, rowLabel, rowValue]]), + [ + expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("记录", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("更新时间", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("记录", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results[3]?.baseline.matched).toBe(true); + expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200); + }); + + it("同一结构单元格内连续段落不应用横向相邻列边界", () => { + const projectName = line("智慧园区一体化平台建设项目", 180, 180, 180); + const reportTitle = line("可行性研究报告", 280, 120, 285); + const reportVersion = line("送审稿V1.0", 360, 84, 330); + const reportDate = line("2026年7月", 440, 72, 336); + const group = { + tableGroupId: "table-structural-cover", + tableRowIndex: 0, + tableColumnIndex: 0, + blockKind: "table-cell" as const, + }; + const expectations = [ + expectation("智慧园区一体化平台建设项目", { index: 0, ...group }), + expectation("可行性研究报告", { index: 1, ...group }), + expectation("送审稿 V1.0", { index: 2, ...group }), + expectation("2026年7月", { index: 3, ...group }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[projectName, reportTitle, reportVersion, reportDate]]), + snapshot("candidate", [[projectName, reportTitle, reportVersion, reportDate]]), + expectations, + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, + true, + true, + true, + ]); + expect(results.map((result) => result.candidate.matched)).toEqual([ + true, + true, + true, + true, + ]); + }); + + it("同一表格行的后一列允许跨到下一 PDF 页继续匹配", () => { + const rowLabel = itemLine("肆式", 780, 24, 80); + const repeatedHeader = itemLine("字段说明", 72, 72, 80); + const rowValue = itemLine("边界", 108, 24, 180); + const group = { + tableGroupId: "table-cross-page-row", + tableRowIndex: 4, + blockKind: "table-cell" as const, + }; + const expectations = [ + expectation("肆式", { index: 0, ...group, tableColumnIndex: 0 }), + expectation("边界", { index: 1, ...group, tableColumnIndex: 1 }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[rowLabel], [repeatedHeader, rowValue]]), + snapshot("candidate", [[rowLabel], [repeatedHeader, rowValue]]), + expectations, + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([true, true]); + expect(results.map((result) => result.candidate.matched)).toEqual([true, true]); + }); + + it("合并视觉行中的完整表头不会被下一数据行的较短左移前缀抢占", () => { + const header = line("门禁内容邻接单元格", 100, 420, 80); + header.items = [ + { text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 420, y: 90, width: 60, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const rowFirst = line("邻甲", 124, 24, 400); + const rowLabel = line("行内代码", 132, 48, 80); + const rowBody = line("单元格正文", 132, 60, 240); + const rowSecond = line("邻乙", 148, 24, 400); + const group = { tableGroupId: "table-merged-header-prefix" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, rowFirst, rowLabel, rowBody, rowSecond]]), + snapshot("candidate", [[header, rowFirst, rowLabel, rowBody, rowSecond]]), + [ + expectation("门禁", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("邻接单元格", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("行内代码", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("单元格正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("邻甲邻乙", { + index: 5, + blockKind: "table-cell", + ...group, + tableRowIndex: 1, + tableColumnIndex: 2, + hardBreakSegments: ["邻甲", "邻乙"], + }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, + true, + true, + true, + true, + true, + ]); + expect(results[2]?.baseline.lineTexts).toEqual(["邻接单元格"]); + expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]); + }); + + it("多段硬换行围绕行中心且首字重复时从当前单元格最早片段开始", () => { + const header = line("场景 内容", 100, 220, 80); + header.items = [ + { text: "场景", normalizedText: "场景", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const first = line("标甲", 180, 24, 240); + const second = line("标乙", 200, 24, 240); + const rowLabel = line("壹式", 210, 24, 80); + const third = line("标丙", 220, 24, 240); + const fourth = line("标丁", 240, 24, 240); + const laterRow = line("大甲", 280, 24, 240); + const group = { tableGroupId: "table-hard-break-row-center" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, first, second, rowLabel, third, fourth, laterRow]]), + snapshot("candidate", [[header, first, second, rowLabel, third, fourth, laterRow]]), + [ + expectation("场景", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("壹式", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("标甲标乙标丙标丁", { + index: 3, + blockKind: "table-cell", + ...group, + tableRowIndex: 1, + tableColumnIndex: 1, + hardBreakSegments: ["标甲", "标乙", "标丙", "标丁"], + }), + ], + ); + + expect(results[3]?.baseline.matched).toBe(true); + expect(results[3]?.candidate.matched).toBe(true); + expect(results[3]?.baseline.lineTexts).toEqual([ + "标甲", + "标乙", + "标丙", + "标丁", + ]); + }); + + it("两段硬换行优先已锚定数据行而不是上方表头的同首字前缀", () => { + const header = line("字段 内容 邻", 100, 380, 80); + header.items = [ + { text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "邻", normalizedText: "邻", bounds: { x: 400, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const row = line("当前行 正文 邻甲", 140, 380, 80); + row.items = [ + { text: "当前行", normalizedText: "当前行", bounds: { x: 80, y: 130, width: 36, height: 12 }, baselineY: 140, hasEol: false }, + { text: "正文", normalizedText: "正文", bounds: { x: 240, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: false }, + { text: "邻甲", normalizedText: "邻甲", bounds: { x: 400, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: true }, + ]; + const rowTail = line("邻乙", 160, 24, 400); + const group = { tableGroupId: "table-two-hard-break-row-anchor" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, row, rowTail]]), + snapshot("candidate", [[header, row, rowTail]]), + [ + expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("邻", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("当前行", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("邻甲邻乙", { + index: 5, + blockKind: "table-cell", + ...group, + tableRowIndex: 1, + tableColumnIndex: 2, + hardBreakSegments: ["邻甲", "邻乙"], + }), + ], + ); + + expect(results[5]?.baseline.matched).toBe(true); + expect(results[5]?.candidate.matched).toBe(true); + expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]); + expect(results[5]?.candidate.lineTexts).toEqual(["邻甲", "邻乙"]); + }); + + it("表格后续列拆行时优先当前行而不是稍后完整同名文本", () => { + const header = line("字段 状态", 100, 220, 80); + header.items = [ + { text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "状态", normalizedText: "状态", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const rowFirst = line("当前行 已实", 200, 220, 80); + rowFirst.items = [ + { text: "当前行", normalizedText: "当前行", bounds: { x: 80, y: 190, width: 36, height: 12 }, baselineY: 200, hasEol: false }, + { text: "已实", normalizedText: "已实", bounds: { x: 240, y: 190, width: 24, height: 12 }, baselineY: 200, hasEol: true }, + ]; + const rowTail = line("现", 213, 12, 240); + const later = line("已实现", 280, 36, 240); + const group = { tableGroupId: "table-split-later-column" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, rowFirst, rowTail, later]]), + snapshot("candidate", [[header, rowFirst, rowTail, later]]), + [ + expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("状态", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("当前行", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("已实现", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results[3]?.baseline.matched).toBe(true); + expect(results[3]?.baseline.lineTexts).toEqual(["已实", "现"]); + expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200); + }); + + it("长行内代码跨多行时按列左边界聚合且不串入同行其他列", () => { + const header = line("字段 说明", 100, 260, 80); + header.items = [ + { text: "字段", normalizedText: "字段", bounds: { x: 100, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "说明", normalizedText: "说明", bounds: { x: 220, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const first = line("nut_prod_sur", 200, 66, 100); + first.items = [ + { text: "nut_prod_sur", normalizedText: "nut_prod_sur", bounds: { x: 100, y: 190, width: 66, height: 12 }, baselineY: 200, hasEol: true }, + ]; + const second = line("plus_disposal 余量处置明细表", 216, 220, 100); + second.items = [ + { text: "plus_disposal", normalizedText: "plus_disposal", bounds: { x: 100, y: 206, width: 72, height: 12 }, baselineY: 216, hasEol: false }, + { text: "余量处置明细表", normalizedText: "余量处置明细表", bounds: { x: 220, y: 206, width: 84, height: 12 }, baselineY: 216, hasEol: true }, + ]; + const third = line("_item", 232, 30, 100); + third.items = [ + { text: "_item", normalizedText: "_item", bounds: { x: 100, y: 222, width: 30, height: 12 }, baselineY: 232, hasEol: true }, + ]; + const group = { tableGroupId: "table-wrapped-inline-code" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, first, second, third]]), + snapshot("candidate", [[header, first, second, third]]), + [ + expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("说明", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("nut_prod_surplus_disposal_item", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0, hasInlineCode: true }), + expectation("余量处置明细表", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results[2]?.baseline.matched).toBe(true); + expect(results[2]?.baseline.lineTexts).toEqual([ + "nut_prod_sur", + "plus_disposal", + "_item", + ]); + expect(results[2]?.baseline.visualLines).toHaveLength(3); + expect(results[3]?.baseline.lineTexts).toEqual(["余量处置明细表"]); + }); + + it("表格短 ASCII 层级拆行时不会被后续行更长前缀抢占", () => { + const header = line("资产名 分层", 100, 220, 80); + header.items = [ + { text: "资产名", normalizedText: "资产名", bounds: { x: 80, y: 90, width: 36, height: 12 }, baselineY: 100, hasEol: false }, + { text: "分层", normalizedText: "分层", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const currentRow = line("餐品营养排 D", 200, 220, 80); + currentRow.items = [ + { text: "餐品营养排", normalizedText: "餐品营养排", bounds: { x: 80, y: 190, width: 72, height: 12 }, baselineY: 200, hasEol: false }, + { text: "D", normalizedText: "D", bounds: { x: 240, y: 190, width: 8, height: 12 }, baselineY: 200, hasEol: true }, + ]; + const currentRowTail = line("名日表 WS", 213, 220, 80); + currentRowTail.items = [ + { text: "名日表", normalizedText: "名日表", bounds: { x: 80, y: 203, width: 36, height: 12 }, baselineY: 213, hasEol: false }, + { text: "WS", normalizedText: "WS", bounds: { x: 240, y: 203, width: 16, height: 12 }, baselineY: 213, hasEol: true }, + ]; + const laterRow = line("后续资产 DW", 360, 220, 80); + laterRow.items = [ + { text: "后续资产", normalizedText: "后续资产", bounds: { x: 80, y: 350, width: 48, height: 12 }, baselineY: 360, hasEol: false }, + { text: "DW", normalizedText: "DW", bounds: { x: 240, y: 350, width: 16, height: 12 }, baselineY: 360, hasEol: true }, + ]; + const laterRowTail = line("S", 373, 8, 240); + const group = { tableGroupId: "table-short-ascii-layer" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, currentRow, currentRowTail, laterRow, laterRowTail]]), + snapshot("candidate", [[header, currentRow, currentRowTail, laterRow, laterRowTail]]), + [ + expectation("资产名", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("分层", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("餐品营养排名日表", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("DWS", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results[3]?.baseline.matched).toBe(true); + expect(results[3]?.candidate.matched).toBe(true); + expect(results[3]?.baseline.lineTexts).toEqual(["D", "WS"]); + expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200); + }); + + it("纵版窄表头允许相邻列在同一逻辑行内上下错位", () => { + const upper = line("DW DW AD 合", 100, 300, 360); + upper.items = [ + { text: "DW", normalizedText: "DW", bounds: { x: 367, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false }, + { text: "DW", normalizedText: "DW", bounds: { x: 409, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false }, + { text: "AD", normalizedText: "AD", bounds: { x: 451, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false }, + { text: "合", normalizedText: "合", bounds: { x: 496, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const lower = line("子节 D S S 计", 121, 320, 204); + lower.items = [ + { text: "子节", normalizedText: "子节", bounds: { x: 204, y: 111, width: 24, height: 12 }, baselineY: 121, hasEol: false }, + { text: "D", normalizedText: "D", bounds: { x: 380, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false }, + { text: "S", normalizedText: "S", bounds: { x: 424, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false }, + { text: "S", normalizedText: "S", bounds: { x: 462, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false }, + { text: "计", normalizedText: "计", bounds: { x: 496, y: 111, width: 12, height: 12 }, baselineY: 121, hasEol: true }, + ]; + const group = { tableGroupId: "table-staggered-header", tableRowIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[upper, lower]]), + snapshot("candidate", [[upper, lower]]), + [ + expectation("子节", { index: 0, blockKind: "table-header", ...group, tableColumnIndex: 0 }), + expectation("DWD", { index: 1, blockKind: "table-header", ...group, tableColumnIndex: 1 }), + expectation("DWS", { index: 2, blockKind: "table-header", ...group, tableColumnIndex: 2 }), + expectation("ADS", { index: 3, blockKind: "table-header", ...group, tableColumnIndex: 3 }), + expectation("合计", { index: 4, blockKind: "table-header", ...group, tableColumnIndex: 4 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, true, + ]); + expect(results[1]?.baseline.lineTexts).toEqual(["DW", "D"]); + }); + + it("首次学习窄 ASCII 表头列时按剩余列域从左到右建立锚点", () => { + const upper = line("DWAD合未实现(设计", 100, 430, 258); + upper.items = [ + { text: "DW", normalizedText: "DW", bounds: { x: 258, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false }, + { text: "AD", normalizedText: "AD", bounds: { x: 349, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false }, + { text: "合", normalizedText: "合", bounds: { x: 396, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: false }, + { text: "未实现(设计", normalizedText: "未实现(设计", bounds: { x: 442, y: 90, width: 70, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const lower = line("承接菜单DDWSS计态)", 114, 440, 81); + lower.items = [ + { text: "承接菜单", normalizedText: "承接菜单", bounds: { x: 81, y: 104, width: 72, height: 12 }, baselineY: 114, hasEol: false }, + { text: "D", normalizedText: "D", bounds: { x: 270, y: 104, width: 9, height: 12 }, baselineY: 114, hasEol: false }, + { text: "DWS", normalizedText: "DWS", bounds: { x: 297, y: 104, width: 27, height: 12 }, baselineY: 114, hasEol: false }, + { text: "S", normalizedText: "S", bounds: { x: 360, y: 104, width: 9, height: 12 }, baselineY: 114, hasEol: false }, + { text: "计", normalizedText: "计", bounds: { x: 396, y: 104, width: 12, height: 12 }, baselineY: 114, hasEol: false }, + { text: "态)", normalizedText: "态)", bounds: { x: 489, y: 104, width: 22, height: 12 }, baselineY: 114, hasEol: true }, + ]; + const group = { tableGroupId: "table-staggered-narrow-ascii", tableRowIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[upper, lower]]), + snapshot("candidate", [[upper, lower]]), + [ + expectation("承接菜单", { index: 0, blockKind: "table-header", ...group, tableColumnIndex: 0 }), + expectation("DWD", { index: 1, blockKind: "table-header", ...group, tableColumnIndex: 1 }), + expectation("DWS", { index: 2, blockKind: "table-header", ...group, tableColumnIndex: 2 }), + expectation("ADS", { index: 3, blockKind: "table-header", ...group, tableColumnIndex: 3 }), + expectation("合计", { index: 4, blockKind: "table-header", ...group, tableColumnIndex: 4 }), + expectation("未实现(设计态)", { index: 5, blockKind: "table-header", ...group, tableColumnIndex: 5 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, true, true, + ]); + expect(results[1]?.baseline.lineTexts).toEqual(["DW", "D"]); + expect(results[2]?.baseline.lineTexts).toEqual(["DWS"]); + expect(results[3]?.baseline.lineTexts).toEqual(["AD", "S"]); + }); + + it("纵向中文表头超过普通行域时不会消费首个数据行的同名字符", () => { + const headerLines = [ + line("实", 100, 12, 463), + line("现", 121, 12, 463), + line("状", 142, 12, 463), + line("表名态", 163, 390, 99), + ]; + headerLines[3]!.items = [ + { text: "表名", normalizedText: "表名", bounds: { x: 99, y: 153, width: 24, height: 12 }, baselineY: 163, hasEol: false }, + { text: "态", normalizedText: "态", bounds: { x: 463, y: 153, width: 12, height: 12 }, baselineY: 163, hasEol: true }, + ]; + const dataFirst = line("首行数据已", 190, 390, 99); + dataFirst.items = [ + { text: "首行数据", normalizedText: "首行数据", bounds: { x: 99, y: 180, width: 48, height: 12 }, baselineY: 190, hasEol: false }, + { text: "已", normalizedText: "已", bounds: { x: 463, y: 180, width: 12, height: 12 }, baselineY: 190, hasEol: true }, + ]; + const dataSecond = line("实", 211, 12, 463); + const dataThird = line("现", 232, 12, 463); + const group = { tableGroupId: "table-tall-vertical-header" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[...headerLines, dataFirst, dataSecond, dataThird]]), + snapshot("candidate", [[...headerLines, dataFirst, dataSecond, dataThird]]), + [ + expectation("表名", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("实现状态", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("首行数据", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("已实现", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, + ]); + expect(results[1]?.baseline.lineTexts).toEqual(["实", "现", "状", "态"]); + expect(results[3]?.baseline.lineTexts).toEqual(["已", "实", "现"]); + }); + + it("跨页单元格续行不会为更近列中心跳到下一数据行", () => { + const pageOne = [ + line("类型 比例", 100, 260, 80), + line("brea 0.3", 200, 260, 80), + ]; + pageOne[0]!.items = [ + { text: "类型", normalizedText: "类型", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false }, + { text: "比例", normalizedText: "比例", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true }, + ]; + pageOne[1]!.items = [ + { text: "brea", normalizedText: "brea", bounds: { x: 80, y: 190, width: 32, height: 12 }, baselineY: 200, hasEol: false }, + { text: "0.3", normalizedText: "0.3", bounds: { x: 240, y: 190, width: 22, height: 12 }, baselineY: 200, hasEol: true }, + ]; + const pageTwo = [ + line("重复表头一", 80, 260, 80), + line("重复表头二", 92, 260, 80), + line("重复表头三", 104, 260, 80), + line("重复表头四", 116, 260, 80), + line("类型 比例", 128, 260, 80), + line("kfast 0", 140, 260, 80), + line("lunc 0.4", 180, 260, 80), + line("h 0", 200, 260, 80), + ]; + pageTwo.forEach((visualLine) => { + const parts = visualLine.normalizedText.split(" "); + visualLine.items = parts.length === 1 + ? [{ text: parts[0]!, normalizedText: parts[0]!, bounds: { x: 80, y: visualLine.baselineY - 10, width: 60, height: 12 }, baselineY: visualLine.baselineY, hasEol: true }] + : [ + { text: parts[0]!, normalizedText: parts[0]!, bounds: { x: 80, y: visualLine.baselineY - 10, width: 40, height: 12 }, baselineY: visualLine.baselineY, hasEol: false }, + { text: parts[1]!, normalizedText: parts[1]!, bounds: { x: parts[1] === "0" && parts[0] === "lunc" ? 244 : 240, y: visualLine.baselineY - 10, width: 24, height: 12 }, baselineY: visualLine.baselineY, hasEol: true }, + ]; + }); + const group = { tableGroupId: "table-cross-page-continuation" }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [pageOne, pageTwo]), + snapshot("candidate", [pageOne, pageTwo]), + [ + expectation("类型", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("比例", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("breakfast", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("0.30", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("lunch", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 0 }), + expectation("0.40", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 1 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, true, true, + ]); + expect(results[3]?.baseline.lineTexts).toEqual(["0.3", "0"]); + expect(results[4]?.baseline.lineTexts).toEqual(["lunc", "h"]); + }); + + it("跨页表格行尾不会吸附后续数据行并跳过下一行长标识符", () => { + const header = line("资产表注册分层说明状态", 700, 490, 47); + header.items = [ + { text: "资产表", normalizedText: "资产表", bounds: { x: 47, y: 690, width: 36, height: 12 }, baselineY: 700, hasEol: false }, + { text: "注册", normalizedText: "注册", bounds: { x: 183, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false }, + { text: "分层", normalizedText: "分层", bounds: { x: 292, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false }, + { text: "说明", normalizedText: "说明", bounds: { x: 330, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false }, + { text: "状态", normalizedText: "状态", bounds: { x: 499, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: true }, + ]; + const rowOneFirst = line( + "dwd_snd_terminal_fau(未注册资产D终端故障+抄表明细未实现", + 764, + 490, + 47, + ); + rowOneFirst.items = [ + { text: "dwd_snd_terminal_fau", normalizedText: "dwd_snd_terminal_fau", bounds: { x: 47, y: 754, width: 116, height: 12 }, baselineY: 764, hasEol: false }, + { text: "(未注册资产", normalizedText: "(未注册资产", bounds: { x: 183, y: 754, width: 72, height: 12 }, baselineY: 764, hasEol: false }, + { text: "D", normalizedText: "D", bounds: { x: 292, y: 754, width: 9, height: 12 }, baselineY: 764, hasEol: false }, + { text: "终端故障+抄表明细", normalizedText: "终端故障+抄表明细", bounds: { x: 330, y: 754, width: 102, height: 12 }, baselineY: 764, hasEol: false }, + { text: "未实现", normalizedText: "未实现", bounds: { x: 499, y: 754, width: 36, height: 12 }, baselineY: 764, hasEol: true }, + ]; + const rowOneSecond = line("lt_inc名)W(设计", 785, 490, 47); + rowOneSecond.items = [ + { text: "lt_inc", normalizedText: "lt_inc", bounds: { x: 47, y: 775, width: 35, height: 12 }, baselineY: 785, hasEol: false }, + { text: "名)", normalizedText: "名)", bounds: { x: 183, y: 775, width: 24, height: 12 }, baselineY: 785, hasEol: false }, + { text: "W", normalizedText: "W", bounds: { x: 292, y: 775, width: 13, height: 12 }, baselineY: 785, hasEol: false }, + { text: "(设计", normalizedText: "(设计", bounds: { x: 499, y: 775, width: 28, height: 12 }, baselineY: 785, hasEol: true }, + ]; + + const repeatedHeader = line("表名资产名层设计目标态", 81, 490, 47); + repeatedHeader.items = [ + { text: "表名资产名层设计目标", normalizedText: "表名资产名层设计目标", bounds: { x: 47, y: 71, width: 430, height: 12 }, baselineY: 81, hasEol: false }, + { text: "态", normalizedText: "态", bounds: { x: 515, y: 71, width: 12, height: 12 }, baselineY: 81, hasEol: true }, + ]; + const rowOneTail = line("D态)", 115, 490, 292); + rowOneTail.items = [ + { text: "D", normalizedText: "D", bounds: { x: 292, y: 105, width: 9, height: 12 }, baselineY: 115, hasEol: false }, + { text: "态)", normalizedText: "态)", bounds: { x: 499, y: 105, width: 16, height: 12 }, baselineY: 115, hasEol: true }, + ]; + const rowTwoFirst = line("dwd_snd_user_account(未注册资产D用户账户未实现", 149, 490, 47); + rowTwoFirst.items = [ + { text: "dwd_snd_user_account", normalizedText: "dwd_snd_user_account", bounds: { x: 47, y: 139, width: 116, height: 12 }, baselineY: 149, hasEol: false }, + { text: "(未注册资产", normalizedText: "(未注册资产", bounds: { x: 183, y: 139, width: 72, height: 12 }, baselineY: 149, hasEol: false }, + { text: "D", normalizedText: "D", bounds: { x: 292, y: 139, width: 9, height: 12 }, baselineY: 149, hasEol: false }, + { text: "用户账户", normalizedText: "用户账户", bounds: { x: 330, y: 139, width: 48, height: 12 }, baselineY: 149, hasEol: false }, + { text: "未实现", normalizedText: "未实现", bounds: { x: 499, y: 139, width: 36, height: 12 }, baselineY: 149, hasEol: true }, + ]; + const rowTwoSecond = line("_detail名)W(设计", 171, 490, 47); + rowTwoSecond.items = [ + { text: "_detail", normalizedText: "_detail", bounds: { x: 47, y: 161, width: 41, height: 12 }, baselineY: 171, hasEol: false }, + { text: "名)", normalizedText: "名)", bounds: { x: 183, y: 161, width: 24, height: 12 }, baselineY: 171, hasEol: false }, + { text: "W", normalizedText: "W", bounds: { x: 292, y: 161, width: 13, height: 12 }, baselineY: 171, hasEol: false }, + { text: "(设计", normalizedText: "(设计", bounds: { x: 499, y: 161, width: 28, height: 12 }, baselineY: 171, hasEol: true }, + ]; + const rowTwoTail = line("D态)", 192, 490, 292); + rowTwoTail.items = [ + { text: "D", normalizedText: "D", bounds: { x: 292, y: 182, width: 9, height: 12 }, baselineY: 192, hasEol: false }, + { text: "态)", normalizedText: "态)", bounds: { x: 499, y: 182, width: 16, height: 12 }, baselineY: 192, hasEol: true }, + ]; + const group = { tableGroupId: "table-cross-page-row-tail" }; + const expectations = [ + expectation("资产表", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("注册", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("分层", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("说明", { index: 3, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 3 }), + expectation("状态", { index: 4, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 4 }), + expectation("dwd_snd_terminal_fault_inc", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("(未注册资产名)", { index: 6, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("DWD", { index: 7, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }), + expectation("终端故障+抄表明细", { index: 8, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 3 }), + expectation("未实现(设计态)", { index: 9, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 4 }), + expectation("dwd_snd_user_account_detail", { index: 10, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 0 }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, rowOneFirst, rowOneSecond], [repeatedHeader, rowOneTail, rowTwoFirst, rowTwoSecond, rowTwoTail]]), + snapshot("candidate", [[header, rowOneFirst, rowOneSecond], [repeatedHeader, rowOneTail, rowTwoFirst, rowTwoSecond, rowTwoTail]]), + expectations, + ); + + expect(results.map((result) => result.baseline.matched)).toEqual( + expectations.map(() => true), + ); + expect(results[9]?.baseline.visualLines.at(-1)?.bounds.y).toBe(105); + expect(results[9]?.baseline.lineTexts).toEqual([ + "未实现", + "(设计", + "态)", + ]); + expect(results[10]?.baseline.lineTexts).toEqual([ + "dwd_snd_user_account", + "_detail", + ]); + }); + + it("表格首列被拆行时不会被稍后正文中的完整同名词夺走行锚点", () => { + const header = line("层级 数据性质 具体做法", 100, 440, 78); + header.items = [ + { text: "层级", normalizedText: "层级", bounds: { x: 78, y: 90, width: 20, height: 12 }, baselineY: 100, hasEol: false }, + { text: "数据性质", normalizedText: "数据性质", bounds: { x: 106, y: 90, width: 40, height: 12 }, baselineY: 100, hasEol: false }, + { text: "具体做法", normalizedText: "具体做法", bounds: { x: 164, y: 90, width: 220, height: 12 }, baselineY: 100, hasEol: true }, + ]; + const dwsFirst = line("D 计算 按维度日聚合", 200, 360, 78); + dwsFirst.items = [ + { text: "D", normalizedText: "D", bounds: { x: 78, y: 190, width: 8, height: 12 }, baselineY: 200, hasEol: false }, + { text: "计算", normalizedText: "计算", bounds: { x: 106, y: 190, width: 20, height: 12 }, baselineY: 200, hasEol: false }, + { text: "按维度日聚合", normalizedText: "按维度日聚合", bounds: { x: 164, y: 190, width: 72, height: 12 }, baselineY: 200, hasEol: true }, + ]; + const dwsTail = line("WS", 213, 16, 78); + const laterBody = line("加工;DWS、ADS 两层是计算", 280, 180, 72); + const group = { tableGroupId: "table-split-row-anchor" }; + const expectations = [ + expectation("层级", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("数据性质", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("具体做法", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }), + expectation("DWS", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("计算", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + expectation("按维度日聚合", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[header, dwsFirst, dwsTail, laterBody]]), + snapshot("candidate", [[header, dwsFirst, dwsTail, laterBody]]), + expectations, + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, true, true, true, true, + ]); + expect(results[3]?.baseline.lineTexts).toEqual(["D", "WS"]); + expect(results[4]?.baseline.firstLineBaselineYPt).toBe(200); + }); + + it("长标识符在文本层仅缺失少量末尾字形时恢复表头观测", () => { + const clipped = line("is_workday_requir", 100, 112, 680); + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[clipped]]), + snapshot("candidate", [[clipped]]), + [expectation("is_workday_required", { + blockKind: "table-header", + tableGroupId: "table-clipped-header", + tableRowIndex: 0, + tableColumnIndex: 7, + })], + ); + + expect(result?.baseline.matched).toBe(true); + expect(result?.baseline.matchedCharacterCount).toBe(17); + expect(result?.baseline.lineTexts).toEqual(["is_workday_requir"]); + }); + + it("长标识符缺失超过十五个百分点时仍拒绝恢复表头观测", () => { + const clipped = line("is_workday_req", 100, 96, 680); + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[clipped]]), + snapshot("candidate", [[clipped]]), + [expectation("is_workday_required", { + blockKind: "table-header", + tableGroupId: "table-over-clipped-header", + tableRowIndex: 0, + tableColumnIndex: 7, + })], + ); + + expect(result?.baseline.matched).toBe(false); + }); + + it("双行表头续字不会为更近列坐标跨入下一数据行", () => { + const headerFirst = line("meal_typ", 100, 64, 80); + const headerTail = line("e", 118, 8, 92); + const dataFirst = line("breakfas", 145, 64, 80); + const dataTail = line("t", 163, 8, 80); + headerFirst.items = [{ text: "meal_typ", normalizedText: "meal_typ", bounds: { x: 80, y: 90, width: 64, height: 12 }, baselineY: 100, hasEol: true }]; + headerTail.items = [{ text: "e", normalizedText: "e", bounds: { x: 92, y: 108, width: 8, height: 12 }, baselineY: 118, hasEol: true }]; + dataFirst.items = [{ text: "breakfas", normalizedText: "breakfas", bounds: { x: 80, y: 135, width: 64, height: 12 }, baselineY: 145, hasEol: true }]; + dataTail.items = [{ text: "t", normalizedText: "t", bounds: { x: 80, y: 153, width: 8, height: 12 }, baselineY: 163, hasEol: true }]; + const group = { tableGroupId: "table-header-near-next-row", tableColumnIndex: 0 }; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[headerFirst, headerTail, dataFirst, dataTail]]), + snapshot("candidate", [[headerFirst, headerTail, dataFirst, dataTail]]), + [ + expectation("meal_type", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0 }), + expectation("breakfast", { index: 1, blockKind: "table-cell", ...group, tableRowIndex: 1 }), + ], + ); + + expect(results.map((result) => result.baseline.matched)).toEqual([ + true, true, + ]); + expect(results[0]?.baseline.lineTexts).toEqual(["meal_typ", "e"]); + expect(results[1]?.baseline.lineTexts).toEqual(["breakfas", "t"]); + }); + + it("新表首列保持最早视觉行的最左前缀而不被稍后更长前缀抢占", () => { + const visualLines = [ + line("mea", 100, 30, 100), + line("meal", 120, 40, 400), + line("l_type", 140, 50, 100), + ]; + const paragraphs = [ + expectation("meal_type", { + index: 0, + blockKind: "table-header", + tableGroupId: "table-leftmost-prefix", + tableRowIndex: 0, + tableColumnIndex: 0, + }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [visualLines]), + snapshot("candidate", [visualLines]), + paragraphs, + ); + + expect(results[0]?.baseline.matched).toBe(true); + expect(results[0]?.candidate.matched).toBe(true); + expect(results[0]?.baseline.lineTexts).toEqual(["mea", "l_type"]); + }); + it("按字符类别估算合并文本项中的有序列表正文边界", () => { const listLine = line("3.系统联调", 220, 100, 100); listLine.items = [ @@ -115,8 +2025,31 @@ describe("PDF 内容感知段落版式门禁", () => { [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); + expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(123.48, 1); + expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(76.52, 1); + }); + + it("从被归一化行剥离的项目符号后开始裁剪正文", () => { + const listLine = line("系统联调", 220, 100, 100); + listLine.items = [ + { + text: "• 系统联调", + normalizedText: "• 系统联调", + 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).toBeGreaterThan(100); + expect(result?.baseline.visualLines[0]?.bounds.width).toBeLessThan(100); + expect(result?.baseline.lineTexts).toEqual(["系统联调"]); }); it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => { @@ -414,4 +2347,40 @@ describe("PDF 内容感知段落版式门禁", () => { "PARAGRAPH_LINE_GEOMETRY_MISMATCH", ); }); + + it("表格段落按各自列左锚点归一化横向几何", () => { + const group = { + tableGroupId: "table-column-relative-geometry", + blockKind: "table-cell" as const, + }; + const baseline = snapshot("baseline", [[ + itemLine("甲项", 120, 24, 80), + itemLine("甲值", 120, 24, 180), + itemLine("乙项", 150, 24, 80), + itemLine("乙值", 150, 24, 180), + ]]); + const candidate = snapshot("candidate", [[ + itemLine("甲项", 120, 24, 79), + itemLine("甲值", 120, 24, 177.5), + itemLine("乙项", 150, 24, 79), + itemLine("乙值", 150, 24, 177.5), + ]]); + const results = comparePdfEditableParagraphLayouts( + baseline, + candidate, + [ + expectation("甲项", { index: 0, ...group, tableRowIndex: 0, tableColumnIndex: 0 }), + expectation("甲值", { index: 1, ...group, tableRowIndex: 0, tableColumnIndex: 1 }), + expectation("乙项", { index: 2, ...group, tableRowIndex: 1, tableColumnIndex: 0 }), + expectation("乙值", { index: 3, ...group, tableRowIndex: 1, tableColumnIndex: 1 }), + ], + ); + + expect(results.map((result) => result.status)).toEqual([ + "passed", + "passed", + "passed", + "passed", + ]); + }); }); diff --git a/packages/document-visual-diff/tests/raster.test.ts b/packages/document-visual-diff/tests/raster.test.ts index ceb0965..1e0ec60 100644 --- a/packages/document-visual-diff/tests/raster.test.ts +++ b/packages/document-visual-diff/tests/raster.test.ts @@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => { meanAbsoluteError: 0, changedPixelRatio: 0, inkIou: 1, + foregroundInkIou: 1, edgeIou: 1, backgroundColorDelta: 0, foregroundColorDelta: 0, @@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => { expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1); }); + it("双背景占比翻转时仍独立量化核心字形拓扑", async () => { + const inlineCodeRaster = (fullHeightShading: boolean) => { + const canvas = createCanvas(216, 25); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 216, 25); + context.fillStyle = "#f3f3f3"; + context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16); + context.fillStyle = "#333333"; + context.fillRect(34, 10, 80, 4); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 216, + heightPx: 25, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + } satisfies PdfPageRaster; + }; + const result = await comparePageRasters( + inlineCodeRaster(false), + inlineCodeRaster(true), + ); + + expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10); + expect(result.metrics.foregroundInkIou).toBe(1); + }); + + it("渐变背景不会把深色文字误判为背景", async () => { + const gradientRaster = (gradient: boolean) => { + const canvas = createCanvas(160, 40); + const context = canvas.getContext("2d"); + if (gradient) { + const fill = context.createLinearGradient(0, 0, 160, 0); + fill.addColorStop(0, "#edf5fa"); + fill.addColorStop(1, "#ffffff"); + context.fillStyle = fill; + } else { + context.fillStyle = "#f6fafc"; + } + context.fillRect(0, 0, 160, 40); + context.fillStyle = "#0b3557"; + context.fillRect(4, 4, 148, 32); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 160, + heightPx: 40, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + } satisfies PdfPageRaster; + }; + + const result = await comparePageRasters( + gradientRaster(true), + gradientRaster(false), + ); + expect(result.metrics.foregroundColorDelta).toBeLessThan(1); + expect(result.metrics.backgroundColorDelta).toBeLessThan(8); + expect(result.metrics.inkIou).toBeGreaterThan(0.95); + }); + + it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => { + const boxedRaster = (exposeOutside: boolean) => { + const canvas = createCanvas(80, 40); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 80, 40); + context.fillStyle = "#464646"; + context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40); + context.fillStyle = "#ffffff"; + context.fillRect(18, 10, 32, 16); + 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 result = await comparePageRasters( + boxedRaster(true), + boxedRaster(false), + ); + expect(result.metrics.backgroundColorDelta).toBeLessThan(1); + expect(result.metrics.foregroundColorDelta).toBeLessThan(1); + }); + it("量化位移并生成稳定叠加图与热力图", async () => { const result = await comparePageRasters(raster(10), raster(20), { spatialTolerancePx: 0, diff --git a/packages/document-visual-diff/tests/report.test.ts b/packages/document-visual-diff/tests/report.test.ts index 10c71da..229c4e0 100644 --- a/packages/document-visual-diff/tests/report.test.ts +++ b/packages/document-visual-diff/tests/report.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { createPdfVisualDiffReport, getBlockingVisualDiffIssues, + isCrossEngineRasterEquivalent, renderPdfVisualDiffHtml, serializePdfVisualDiffJson, type PdfDocumentSnapshot, @@ -84,6 +85,27 @@ function antialiasBlockRaster(edgeColor: string): PdfPageRaster { }; } +function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster { + const canvas = createCanvas(40, 50); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 40, 50); + if (includePreviousLine) { + context.fillStyle = "#111111"; + context.fillRect(8, 12, 20, 3); + } + context.fillStyle = "#111111"; + context.fillRect(8, 20, 20, 4); + 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, @@ -561,6 +583,58 @@ describe("PDF 视觉差异报告", () => { }); }); + it("正文页数不同时分别验证未配对页面自身的页码语义", async () => { + const pageNumber = (text: string): PdfTextLineSnapshot => ({ + text, + normalizedText: text, + bounds: { x: 9, y: 22, width: 2, height: 1 }, + baselineY: 23, + role: "page-number", + items: [], + }); + const baseline = flowSnapshot( + "baseline", + [ + [contentLine("甲", 8), pageNumber("1 / 2")], + [contentLine("乙丙", 8), pageNumber("2 / 2")], + ], + "#111111", + ); + const candidate = flowSnapshot( + "candidate", + [[ + contentLine("甲", 8), + contentLine("乙丙", 12), + pageNumber("1 / 1"), + ]], + "#cc0000", + ); + const pageSemantics = (count: number) => bodySemantics(count).map( + (item, index) => ({ + ...item, + footerVisible: true, + footerAlignment: "center" as const, + pageNumberText: `${index + 1} / ${count}`, + }), + ); + const report = await createPdfVisualDiffReport(baseline, candidate, { + expectedEditableText: "甲乙丙", + expectedEditableParagraphs: flowParagraphs, + baselinePageSemantics: pageSemantics(2), + candidatePageSemantics: pageSemantics(1), + }); + + expect(report.status).toBe("warning"); + expect(report.basic.bodyFlow).toMatchObject({ + legal: true, + baselineBodyPageCount: 2, + candidateBodyPageCount: 1, + }); + expect(report.issues.map((issue) => issue.code)).toContain( + "BODY_FLOW_PAGE_COUNT_DIFFERENCE", + ); + }); + it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => { const pageNumber = (text: string): PdfTextLineSnapshot => ({ text, @@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => { .toMatchObject({ inkIou: 1, edgeIou: 1 }); }); + it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => { + const targetLine = contentLine("乙", 10); + targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 }; + const baseline = flowSnapshot( + "baseline", + [[targetLine]], + "#111111", + ); + const candidate = flowSnapshot( + "candidate", + [[targetLine]], + "#111111", + ); + baseline.pages[0]!.raster = adjacentLineRaster(true); + candidate.pages[0]!.raster = adjacentLineRaster(false); + const report = await createPdfVisualDiffReport(baseline, candidate, { + expectedEditableText: "乙", + expectedEditableParagraphs: [{ + index: 0, + text: "乙", + role: "body", + section: "body", + blockKind: "table-cell", + }], + pageSemantics: bodySemantics(1), + }); + + expect(report.issues.map((issue) => issue.code)).not.toContain( + "SEMANTIC_BLOCK_RASTER_MISMATCH", + ); + }); + + it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => { + const metrics = { + baselineWidthPx: 236, + baselineHeightPx: 24, + candidateWidthPx: 233, + candidateHeightPx: 26, + comparedWidthPx: 236, + comparedHeightPx: 26, + dimensionsMatch: false, + geometryNormalized: true, + spatialTolerancePx: 4, + meanAbsoluteError: 22.1, + changedPixelRatio: 0.449, + inkIou: 0.908, + edgeIou: 1, + backgroundColorDelta: 11.38, + foregroundColorDelta: 2.6, + }; + + expect(isCrossEngineRasterEquivalent( + metrics, + false, + "paragraph", + true, + )).toBe(true); + expect(isCrossEngineRasterEquivalent( + metrics, + false, + "paragraph", + false, + )).toBe(false); + }); + it("缺少语义块证据时不得把正文整页栅格自动降级", async () => { const report = await createPdfVisualDiffReport( snapshot("baseline", raster("#111111")), diff --git a/packages/document-visual-diff/tests/semantic-block.test.ts b/packages/document-visual-diff/tests/semantic-block.test.ts index bf65d16..d44a6d5 100644 --- a/packages/document-visual-diff/tests/semantic-block.test.ts +++ b/packages/document-visual-diff/tests/semantic-block.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import { createCanvas } from "@napi-rs/canvas"; import { + comparePdfSemanticBlockVisuals, isCrossEngineRasterEquivalent, type PdfRasterMetrics, } from "../src/index.js"; @@ -28,7 +30,289 @@ function metrics( }; } +function whitePageRaster() { + const canvas = createCanvas(400, 400); + const context = canvas.getContext("2d"); + context.fillStyle = "#fff"; + context.fillRect(0, 0, 400, 400); + context.fillStyle = "#111"; + context.fillRect(40, 40, 120, 12); + context.fillRect(40, 80, 120, 12); + const rgba = context.getImageData(0, 0, 400, 400).data; + return { + widthPx: 400, + heightPx: 400, + dpi: 72, + sha256: "test", + png: Uint8Array.from(canvas.toBuffer("image/png")), + rgba, + }; +} + +function blankPageRaster() { + const canvas = createCanvas(400, 400); + const context = canvas.getContext("2d"); + context.fillStyle = "#fff"; + context.fillRect(0, 0, 400, 400); + const rgba = context.getImageData(0, 0, 400, 400).data; + return { + widthPx: 400, + heightPx: 400, + dpi: 72, + sha256: "blank", + png: Uint8Array.from(canvas.toBuffer("image/png")), + rgba, + }; +} + describe("跨引擎字形栅格等价", () => { + it("缺少局部视觉行时必须阻断而不能静默通过", async () => { + const [comparison] = await comparePdfSemanticBlockVisuals( + { pages: [] } as never, + { pages: [] } as never, + [{ + expectation: { index: 39, section: "body", blockKind: "table-cell" }, + baseline: { matched: true, visualLines: [], lineTexts: [] }, + candidate: { matched: true, visualLines: [], lineTexts: [] }, + }] as never, + {} as never, + ); + + expect(comparison?.status).toBe("failed"); + expect(comparison?.issues).toEqual([ + expect.objectContaining({ + code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE", + severity: "failure", + }), + ]); + }); + + it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => { + const page = { + pageNumber: 1, + widthPt: 400, + heightPt: 400, + lines: [], + raster: whitePageRaster(), + }; + const visualLine = (y: number) => ({ + pageNumber: 1, + bounds: { x: 40, y, width: 120, height: 12 }, + fontFamilies: ["Test Sans"], + }); + const [comparison] = await comparePdfSemanticBlockVisuals( + { pages: [page] } as never, + { pages: [page] } as never, + [{ + expectation: { + index: 7, + section: "body", + blockKind: "table-cell", + }, + baseline: { + matched: true, + visualLines: [visualLine(40), visualLine(80)], + lineTexts: ["甲乙", "丙丁"], + }, + candidate: { + matched: true, + visualLines: [visualLine(40)], + lineTexts: ["甲乙丙丁"], + }, + }] as never, + { + pixelDifferenceThreshold: 8, + spatialTolerancePx: 4, + minInkIou: 0.75, + minEdgeIou: 0.75, + maxBackgroundColorDelta: 16, + maxForegroundColorDelta: 16, + maxMeanAbsoluteError: 20, + maxChangedPixelRatio: 0.35, + minAntialiasEquivalentInkIou: 0.9, + minAntialiasEquivalentEdgeIou: 0.9, + } as never, + ); + + expect(comparison?.status).toBe("warning"); + expect(comparison?.lines).toHaveLength(2); + expect(comparison?.issues).not.toContainEqual( + expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }), + ); + expect(comparison?.lines.every((line) => + line.issues.some((issue) => issue.details?.textReflow === true) + )).toBe(true); + }); + + it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => { + const page = { + pageNumber: 1, + widthPt: 400, + heightPt: 400, + lines: [], + raster: blankPageRaster(), + }; + const visualLine = (y: number) => ({ + pageNumber: 1, + bounds: { x: 40, y, width: 108, height: 12 }, + fontFamilies: ["Test Sans"], + }); + const [comparison] = await comparePdfSemanticBlockVisuals( + { pages: [page] } as never, + { pages: [page] } as never, + [{ + expectation: { + index: 118, + section: "body", + blockKind: "table-header", + }, + baseline: { + matched: true, + matchedCharacterCount: 10, + expectedCharacterCount: 10, + visualLines: [visualLine(40), visualLine(70)], + lineTexts: ["高血压预测模型目标", "值"], + }, + candidate: { + matched: false, + matchedCharacterCount: 9, + expectedCharacterCount: 10, + visualLines: [visualLine(40)], + lineTexts: ["高血压预测模型目标"], + }, + }] as never, + { + pixelDifferenceThreshold: 8, + spatialTolerancePx: 4, + minInkIou: 0.75, + minEdgeIou: 0.75, + maxBackgroundColorDelta: 16, + maxForegroundColorDelta: 16, + maxMeanAbsoluteError: 20, + maxChangedPixelRatio: 0.35, + minAntialiasEquivalentInkIou: 0.9, + minAntialiasEquivalentEdgeIou: 0.9, + } as never, + ); + + expect(comparison?.lines).toHaveLength(1); + expect(comparison?.issues).not.toContainEqual( + expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }), + ); + }); + + it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => { + const page = { + pageNumber: 1, + widthPt: 400, + heightPt: 400, + lines: [], + raster: blankPageRaster(), + }; + const visualLine = { + pageNumber: 1, + bounds: { x: 40, y: 40, width: 108, height: 12 }, + fontFamilies: ["Test Serif"], + }; + const [comparison] = await comparePdfSemanticBlockVisuals( + { pages: [page] } as never, + { pages: [page] } as never, + [{ + expectation: { + index: 71, + section: "body", + blockKind: "table-cell", + mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13], + }, + baseline: { + matched: false, + matchedCharacterCount: 12, + expectedCharacterCount: 14, + visualLines: [visualLine], + lineTexts: ["收缩压≥140或舒张压≥"], + }, + candidate: { + matched: true, + matchedCharacterCount: 14, + expectedCharacterCount: 14, + visualLines: [visualLine], + lineTexts: ["收缩压≥140或舒张压≥90"], + }, + }] as never, + { + pixelDifferenceThreshold: 8, + spatialTolerancePx: 4, + minInkIou: 0.75, + minEdgeIou: 0.75, + maxBackgroundColorDelta: 16, + maxForegroundColorDelta: 16, + maxMeanAbsoluteError: 20, + maxChangedPixelRatio: 0.35, + minAntialiasEquivalentInkIou: 0.9, + minAntialiasEquivalentEdgeIou: 0.9, + } as never, + ); + + expect(comparison?.lines).toHaveLength(1); + expect(comparison?.issues).not.toContainEqual( + expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }), + ); + }); + + it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => { + const page = { + pageNumber: 1, + widthPt: 400, + heightPt: 400, + lines: [], + raster: blankPageRaster(), + }; + const visualLine = (y: number, width: number) => ({ + pageNumber: 1, + bounds: { x: 40, y, width, height: 12 }, + fontFamilies: ["Test Sans"], + }); + const [comparison] = await comparePdfSemanticBlockVisuals( + { pages: [page] } as never, + { pages: [page] } as never, + [{ + expectation: { + index: 117, + section: "body", + blockKind: "table-header", + }, + baseline: { + matched: true, + visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)], + lineTexts: ["记", "录/", "计算"], + }, + candidate: { + matched: true, + visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)], + lineTexts: ["记录", "/计", "算"], + }, + }] as never, + { + pixelDifferenceThreshold: 8, + spatialTolerancePx: 4, + minInkIou: 0.75, + minEdgeIou: 0.75, + maxBackgroundColorDelta: 16, + maxForegroundColorDelta: 16, + maxMeanAbsoluteError: 20, + maxChangedPixelRatio: 0.35, + minAntialiasEquivalentInkIou: 0.9, + minAntialiasEquivalentEdgeIou: 0.9, + } as never, + ); + + expect(comparison?.status).toBe("warning"); + expect(comparison?.lines).toHaveLength(5); + expect(comparison?.issues).not.toContainEqual( + expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }), + ); + }); + it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => { expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true); expect( @@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => { false, ), ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 56, + candidateWidthPx: 56, + baselineHeightPx: 37, + candidateHeightPx: 37, + comparedWidthPx: 56, + comparedHeightPx: 37, + inkIou: 1, + edgeIou: 1, + foregroundColorDelta: 17, + baselineForegroundChroma: 0, + candidateForegroundChroma: 0, + foregroundLuminanceDelta: 17, + }), + false, + "code-block", + ), + ).toBe(true); + const neutralSingleStrokeMetrics = metrics({ + baselineWidthPx: 37, + candidateWidthPx: 37, + baselineHeightPx: 27, + candidateHeightPx: 27, + comparedWidthPx: 37, + comparedHeightPx: 27, + meanAbsoluteError: 12.78, + inkIou: 1, + foregroundInkIou: 1, + edgeIou: 1, + backgroundColorDelta: 10.54, + foregroundColorDelta: 108.69, + baselineForegroundChroma: 10, + candidateForegroundChroma: 5, + foregroundLuminanceDelta: 109.27, + }); + expect( + isCrossEngineRasterEquivalent( + neutralSingleStrokeMetrics, + false, + "table-cell", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + { ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 }, + false, + "table-cell", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 }, + false, + "table-cell", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...neutralSingleStrokeMetrics, edgeIou: 0.994 }, + false, + "table-cell", + ), + ).toBe(false); expect( isCrossEngineRasterEquivalent( metrics({ inkIou: 1, edgeIou: 0.946 }), @@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => { "list-item", ), ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 262, + candidateWidthPx: 272, + baselineHeightPx: 30, + candidateHeightPx: 31, + comparedWidthPx: 262, + comparedHeightPx: 30, + inkIou: 0.9792, + edgeIou: 0.9869, + foregroundColorDelta: 4.63, + baselineForegroundChroma: 0, + candidateForegroundChroma: 0, + foregroundLuminanceDelta: 4.63, + }), + false, + "list-item", + ), + ).toBe(true); expect( isCrossEngineRasterEquivalent( metrics({ @@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => { ).toBe(true); }); + it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => { + const paletteMetrics = metrics({ + inkIou: 1, + edgeIou: 1, + foregroundColorDelta: 35, + baselineForegroundChroma: 141, + candidateForegroundChroma: 176, + dominantForegroundColorDelta: 0.5, + }); + expect( + isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + { ...paletteMetrics, dominantForegroundColorDelta: 4.1 }, + false, + "table-cell", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...paletteMetrics, edgeIou: 0.994 }, + false, + "table-cell", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...paletteMetrics, baselineForegroundChroma: 79 }, + false, + "table-cell", + ), + ).toBe(false); + }); + + it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => { + const syntaxMetrics = metrics({ + baselineWidthPx: 407, + candidateWidthPx: 403, + baselineHeightPx: 22, + candidateHeightPx: 23, + inkIou: 0.988, + foregroundInkIou: 1, + edgeIou: 1, + foregroundColorDelta: 92, + chromaticPaletteDelta: 0.8, + }); + expect( + isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + { ...syntaxMetrics, chromaticPaletteDelta: 2.1 }, + false, + "code-block", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...syntaxMetrics, foregroundInkIou: 0.994 }, + false, + "code-block", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"), + ).toBe(false); + }); + + it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => { + const inlineCodeMetrics = metrics({ + baselineWidthPx: 236, + candidateWidthPx: 234, + baselineHeightPx: 24, + candidateHeightPx: 25, + comparedWidthPx: 236, + comparedHeightPx: 25, + inkIou: 0.91759, + edgeIou: 1, + backgroundColorDelta: 11.317, + foregroundColorDelta: 3.158, + }); + + expect( + isCrossEngineRasterEquivalent( + inlineCodeMetrics, + false, + "paragraph", + true, + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + { ...inlineCodeMetrics, foregroundColorDelta: 3.26 }, + false, + "paragraph", + true, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...inlineCodeMetrics, backgroundColorDelta: 12.01 }, + false, + "paragraph", + true, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { ...inlineCodeMetrics, edgeIou: 0.994 }, + false, + "paragraph", + true, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + { + ...inlineCodeMetrics, + inkIou: 0.88, + foregroundInkIou: 0.99, + }, + false, + "table-cell", + true, + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + { + ...inlineCodeMetrics, + inkIou: 0.88, + foregroundInkIou: 0.97, + }, + false, + "table-cell", + true, + ), + ).toBe(false); + }); + it("拒绝回流、颜色、几何或轮廓变化", () => { expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false); expect( @@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => { expect( isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false), ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 262, + candidateWidthPx: 272, + baselineHeightPx: 30, + candidateHeightPx: 31, + inkIou: 0.9792, + edgeIou: 0.9869, + foregroundColorDelta: 4.63, + baselineForegroundChroma: 2.01, + candidateForegroundChroma: 0, + foregroundLuminanceDelta: 4.63, + }), + false, + "list-item", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 262, + candidateWidthPx: 272, + baselineHeightPx: 30, + candidateHeightPx: 31, + inkIou: 0.9792, + edgeIou: 0.9869, + foregroundColorDelta: 5.01, + baselineForegroundChroma: 0, + candidateForegroundChroma: 0, + foregroundLuminanceDelta: 5.01, + }), + false, + "list-item", + ), + ).toBe(false); expect( isCrossEngineRasterEquivalent( metrics({ diff --git a/packages/document-visual-diff/tests/text.test.ts b/packages/document-visual-diff/tests/text.test.ts index 7a15aac..1e4ec31 100644 --- a/packages/document-visual-diff/tests/text.test.ts +++ b/packages/document-visual-diff/tests/text.test.ts @@ -40,9 +40,88 @@ describe("PDF 文本行聚合", () => { ]); }); + it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => { + const left = item("源系统", 10, 100, 30, 10); + const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10); + const right = item("原值", 184, 100, 20, 10); + const lines = aggregatePdfTextLines([left, middle, right], 842); + + expect(lines).toHaveLength(1); + expect(lines[0]?.normalizedText).toBe( + "源系统nut_pickup_order.meal_type原值", + ); + }); + + it("将同行公式的上标片段按横坐标并回正文", () => { + const lines = aggregatePdfTextLines( + [ + item("减因子", 10, 100, 42, 12), + item("e", 53, 97, 8, 15), + item("−λΔt", 62, 96, 24, 10), + item("的动态权重", 87, 100, 70, 12), + ], + 842, + ); + + expect(lines).toHaveLength(1); + expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重"); + }); + + it("将超出正文右缘但紧邻行末的公式片段并回正文", () => { + const lines = aggregatePdfTextLines( + [ + item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12), + item("−λΔt", 495.7, 466.1, 26, 10.2), + item("下一视觉行", 150, 489.7, 60, 12), + ], + 842, + ); + + expect(lines.map((line) => line.normalizedText)).toEqual([ + "性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt", + "下一视觉行", + ]); + }); + it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => { expect(normalizePdfText("戶⼾")).toBe("户户"); - expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风"); + expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe( + "见角长门页风食骨车齐", + ); + }); + + it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => { + expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\''); + }); + + it("保留与全角括号重叠的窄引号文本流顺序", () => { + const lines = aggregatePdfTextLines( + [ + item("主观定性判断", 532.3, 151.5, 58.5), + item("”", 590.8, 151.5, 3.4), + item("(如", 589.3, 151.5, 19.5), + item("“", 608.8, 151.5, 3.4), + item("高心率占比", 612.2, 151.5, 48.8), + ], + 595, + ); + + expect(lines).toHaveLength(1); + expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比'); + }); + + it("忽略 Emoji 文本层可选的变体选择符", () => { + expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示"); + }); + + it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => { + expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区"); + }); + + it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => { + expect(normalizePdfText("10% -- 20%")).toBe("10%–20%"); + expect(normalizePdfText("10% --")).toBe("10%–"); + expect(normalizePdfText("命令 --flag")).toBe("命令 --flag"); }); it("从可编辑正文契约中移除 Word 自动列表装饰符", () => { diff --git a/packages/docx-engine/assets/docx-media-filter.lua b/packages/docx-engine/assets/docx-media-filter.lua index 4f478a7..b6a9c36 100644 --- a/packages/docx-engine/assets/docx-media-filter.lua +++ b/packages/docx-engine/assets/docx-media-filter.lua @@ -98,6 +98,64 @@ local function replace_code_block(block) return pandoc.Para { image } end +local function preserve_raw_html_as_text(inline) + if inline.format == "html" then + return pandoc.Str(inline.text) + end + return inline +end + +local function preserve_raw_html_block_as_text(block) + if block.format == "html" then + return pandoc.Para { pandoc.Str(block.text) } + end + return block +end + +local function replace_table_breaks(table_block) + return table_block:walk { + RawInline = function(inline) + if inline.format ~= "html" then + return inline + end + local normalized = string.lower(inline.text) + if normalized == "
" + or normalized == "
" + or normalized == "
" then + return pandoc.LineBreak() + end + return inline + end + } +end + +local function normalize_task_list(list) + for _, item in ipairs(list.content) do + local block = item[1] + if block ~= nil and (block.t == "Plain" or block.t == "Para") then + local inlines = block.content + local first = inlines[1] + if first ~= nil and first.t == "Str" then + local marker = string.lower(first.text) + if marker == "[x]" then + first.text = "☒" + elseif marker == "[" then + local second = inlines[2] + local third = inlines[3] + if second ~= nil and second.t == "Space" + and third ~= nil and third.t == "Str" + and third.text == "]" then + first.text = "☐" + inlines:remove(3) + inlines:remove(2) + end + end + end + end + end + return list +end + local function append_inlines(target, value) target:extend(pandoc.Inlines(value)) end @@ -167,7 +225,10 @@ local function validate_counts() error( "DOCX media map contains unused " .. kind - .. " resources" + .. " resources: consumed " + .. tostring(counters[kind]) + .. " of " + .. tostring(#media_map[kind]) ) end end @@ -176,8 +237,14 @@ end return { Pandoc = function(document) local transformed = document:walk { + Table = replace_table_breaks + } + transformed = transformed:walk { Image = replace_image, - CodeBlock = replace_code_block + CodeBlock = replace_code_block, + RawInline = preserve_raw_html_as_text, + RawBlock = preserve_raw_html_block_as_text, + BulletList = normalize_task_list } if structure_plan.titlePolicy.metadataTitle == "suppress" then transformed.meta.title = nil diff --git a/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs index 7da96e9..91f9f5e 100644 --- a/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs +++ b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs @@ -424,7 +424,7 @@ for (const theme of themes) { let conversion; try { conversion = await converter.convert({ - markdown, + markdown: rendered.markdownBody, fileName: `${theme.id}.md`, language: rendered.metadata.language, exportConfig, @@ -433,7 +433,7 @@ for (const theme of themes) { metadata: rendered.metadata, semanticDocument: rendered.semanticDocument, fonts: readThemeFonts(theme), - media: createMedia(markdown) + media: createMedia(rendered.markdownBody) }); } catch (error) { throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, { diff --git a/packages/docx-engine/src/document-structure-transform.ts b/packages/docx-engine/src/document-structure-transform.ts index 3f0330e..d793356 100644 --- a/packages/docx-engine/src/document-structure-transform.ts +++ b/packages/docx-engine/src/document-structure-transform.ts @@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable< const WORDPROCESSING_DRAWING_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; +const MATH_NAMESPACE = + "http://schemas.openxmlformats.org/officeDocument/2006/math"; const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"; // Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。 const COVER_PAGE_BREAK_SAFETY_PT = 6; @@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens( const inlineCodeLayouts = documentLayout.inlineCodes ?? []; let inlineCodeCursor = 0; let count = 0; - for (const run of Array.from( + const runs = Array.from( document.getElementsByTagNameNS(WORD_NAMESPACE, "r") - )) { + ); + for (const [runIndex, run] of runs.entries()) { const properties = firstDirectChild( run, WORD_NAMESPACE, @@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens( const isSourceCode = paragraphStyle ? wordAttribute(paragraphStyle, "val") === "SourceCode" : false; + const previousRun = runs[runIndex - 1]; + const nextRun = runs[runIndex + 1]; + const isHtmlTagName = + isSourceCode && + styleId === "KeywordTok" && + previousRun?.parentNode === run.parentNode && + nextRun?.parentNode === run.parentNode && + /^<\/?$/u.test(previousRun.textContent ?? "") && + /^\/?>$/u.test(nextRun.textContent ?? ""); + const isObjectPropertyKey = + isSourceCode && + styleId === "NormalTok" && + nextRun?.parentNode === run.parentNode && + /^\s*:\s*$/u.test(nextRun.textContent ?? ""); const syntaxSlot = isSourceCode && styleId - ? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "") + ? resolvePandocSyntaxStyleSlot( + styleId, + run.textContent ?? "", + { + htmlTagName: isHtmlTagName, + objectPropertyKey: isObjectPropertyKey + } + ) : undefined; const token = isSourceCode ? syntaxSlot @@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens( return count; } +function applyMeasuredEmojiRunColors( + document: XmlDocument, + documentLayout: DocxDocumentLayoutPlan +) { + const measuredRuns = documentLayout.emojiRuns ?? []; + let cursor = 0; + let count = 0; + for (const run of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "r") + )) { + const text = (run.textContent ?? "").normalize("NFC").trim(); + if (!text) { + continue; + } + const measuredIndex = measuredRuns.findIndex( + (layout, index) => index >= cursor && layout.text === text + ); + if (measuredIndex < 0) { + continue; + } + cursor = measuredIndex + 1; + const properties = ensureFirstElement(run, "rPr", "w:rPr"); + removeDirectChildren(properties, WORD_NAMESPACE, "color"); + appendElement(properties, WORD_NAMESPACE, "w:color", { + "w:val": colorValue(measuredRuns[measuredIndex]!.color) + }); + count += 1; + } + return count; +} + function applyTableCellToken( cell: XmlElement, token: DocxSlotStyleToken @@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry( table: XmlElement, properties: XmlElement, layout: DocxDocumentLayoutPlan["tables"][number], - contentWidthTwips: number + contentWidthTwips: number, + leadingCellInsetTwips: number ) { - const tableWidthTwips = Math.max( + const indentTwips = Math.max( + 0, + Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) + + leadingCellInsetTwips + ); + const requestedTableWidthTwips = Math.max( 1, Math.round(contentWidthTwips * layout.widthPercent / 100) ); - const indentTwips = Math.max( - 0, - Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) + const tableWidthTwips = Math.max( + 1, + Math.min( + requestedTableWidthTwips, + Math.max(1, contentWidthTwips - indentTwips) + ) ); const columnWidths = measuredColumnWidths( tableWidthTwips, @@ -2315,7 +2379,11 @@ function applyTables( table, properties, measuredLayout, - contentWidthTwips + contentWidthTwips, + pointsToTwips(Math.max( + tableCellToken?.paddingPt?.left ?? 0, + tableHeaderToken?.paddingPt?.left ?? 0 + )) ); } else { removeDirectChildren( @@ -2427,8 +2495,15 @@ function applyTables( WORD_NAMESPACE, "p" )) { + const properties = paragraphProperties(paragraph); + const wordWrap = ensureFirstElement( + properties, + "wordWrap", + "w:wordWrap" + ); + setWordAttribute(wordWrap, "val", "1"); const spacing = ensureFirstElement( - paragraphProperties(paragraph), + properties, "spacing", "w:spacing" ); @@ -2436,7 +2511,7 @@ function applyTables( setWordAttribute(spacing, "after", "0"); for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) { const automaticSpacing = ensureFirstElement( - paragraphProperties(paragraph), + properties, property, `w:${property}` ); @@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents( String(Math.min(targetLeft, hanging)) ); } + if (measured.alignment) { + const alignment = ensureDirectElement( + properties, + WORD_NAMESPACE, + "w:jc" + ); + setWordAttribute( + alignment, + "val", + measured.alignment === "justify" ? "both" : measured.alignment + ); + } appliedCount += 1; } return appliedCount; @@ -2727,6 +2814,33 @@ function measuredLineBreakOffsetsForParagraph( }); } +function paragraphHasInlineCode(paragraph: XmlElement) { + return Array.from( + paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "rStyle") + ).some((style) => wordAttribute(style, "val") === "VerbatimChar"); +} + +function stabilizeInlineCodeParagraphLineRules(document: XmlDocument) { + let count = 0; + for (const paragraph of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "p") + )) { + if (!paragraphHasInlineCode(paragraph)) { + continue; + } + const spacing = directChildren( + paragraphProperties(paragraph), + WORD_NAMESPACE, + "spacing" + )[0]; + if (spacing && wordAttribute(spacing, "lineRule") === "exact") { + setWordAttribute(spacing, "lineRule", "atLeast"); + count += 1; + } + } + return count; +} + function applyMeasuredTextBlockLineBreaks( document: XmlDocument, documentLayout: DocxDocumentLayoutPlan @@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks( "line", String(pointsToTwips(measured.linePitchPt)) ); - setWordAttribute(spacing, "lineRule", "exact"); + setWordAttribute( + spacing, + "lineRule", + paragraphHasInlineCode(paragraph) ? "atLeast" : "exact" + ); } for (const offset of [...measuredLineBreakOffsetsForParagraph( paragraph, @@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks( return appliedCount; } +function stabilizeTableMathParagraphAlignment(document: XmlDocument) { + let count = 0; + for (const paragraph of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "p") + )) { + const parent = paragraph.parentNode; + if ( + !parent || + parent.nodeType !== 1 || + (parent as XmlElement).namespaceURI !== WORD_NAMESPACE || + (parent as XmlElement).localName !== "tc" + ) { + continue; + } + const content = Array.from(paragraph.childNodes).filter( + (node): node is XmlElement => node.nodeType === 1 + ).filter( + (child) => + !(child.namespaceURI === WORD_NAMESPACE && child.localName === "pPr") + ); + if ( + content.length === 0 || + !content.every( + (child) => + child.namespaceURI === MATH_NAMESPACE && + child.localName === "oMath" + ) + ) { + continue; + } + const run = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + const properties = appendElement(run, WORD_NAMESPACE, "w:rPr"); + appendElement(properties, WORD_NAMESPACE, "w:noProof"); + const text = appendElement(run, WORD_NAMESPACE, "w:t"); + text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve"); + text.textContent = "\u200B"; + count += 1; + } + return count; +} + function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) { const ids = new Set(); for (const entry of tokens.slots) { @@ -3098,6 +3257,99 @@ function disableAutomaticCharacterSpacing(document: XmlDocument) { } } +const PANDOC_ALERT_LABELS = new Set([ + "note", + "tip", + "important", + "warning", + "caution" +]); + +function normalizePandocAlertParagraphStyles( + body: XmlElement, + documentLayout: DocxDocumentLayoutPlan +) { + const children = directChildren(body, WORD_NAMESPACE, "p"); + const measuredAlerts = (documentLayout.textBlocks ?? []).filter( + (block) => block.alertRole !== undefined + ); + let measuredCursor = 0; + let count = 0; + for (const [index, paragraph] of children.entries()) { + if ( + paragraphStyleId(paragraph) !== "FirstParagraph" || + !PANDOC_ALERT_LABELS.has( + normalizedLayoutText(paragraphText(paragraph)).toLocaleLowerCase( + "en-US" + ) + ) + ) { + continue; + } + const content = children[index + 1]; + if (!content || paragraphStyleId(content) !== "BodyText") { + continue; + } + const style = ensureDirectElement( + paragraphProperties(content), + WORD_NAMESPACE, + "w:pStyle" + ); + setWordAttribute(style, "val", "BlockText"); + for (const [target, role] of [ + [paragraph, "title"], + [content, "body"] + ] as const) { + const text = normalizedLayoutText(paragraphText(target)); + const relativeMatchIndex = measuredAlerts + .slice(measuredCursor) + .findIndex( + (block) => + block.alertRole === role && + normalizedLayoutText(block.text) === text + ); + if (relativeMatchIndex < 0) { + continue; + } + const measuredIndex = measuredCursor + relativeMatchIndex; + const measured = measuredAlerts[measuredIndex]!; + measuredCursor = measuredIndex + 1; + const properties = paragraphProperties(target); + const indentation = ensureDirectElement( + properties, + WORD_NAMESPACE, + "w:ind" + ); + if (measured.leftIndentPt !== undefined) { + setWordAttribute( + indentation, + "left", + String(pointsToTwips(measured.leftIndentPt)) + ); + } + if (measured.rightIndentPt !== undefined) { + setWordAttribute( + indentation, + "right", + String(pointsToTwips(measured.rightIndentPt)) + ); + } + applyDirectRunToken(target, { + fontCandidates: [], + ...(measured.fontSizePt !== undefined + ? { fontSizePt: measured.fontSizePt } + : {}), + bold: measured.bold, + italic: measured.italic, + color: measured.color, + letterSpacingPt: measured.letterSpacingPt + }); + } + count += 1; + } + return count; +} + export function finalizeGeneratedDocxStructure( content: Uint8Array, plan: PandocStructurePlan, @@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure( containers.editableContainerTables.size > 0, usesEvenAndOddPages ); + normalizePandocAlertParagraphStyles(body, documentLayout); disableAutomaticCharacterSpacing(document); const final = finalSection(body); applyMeasuredTextBlockLineBreaks(document, documentLayout); @@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure( ); applyMeasuredTextBlockAlignments(document, documentLayout); applyDirectCharacterStyleTokens(document, tokens, documentLayout); + applyMeasuredEmojiRunColors(document, documentLayout); + stabilizeInlineCodeParagraphLineRules(document); + stabilizeTableMathParagraphAlignment(document); const mediaDrawingCount = normalizeMediaParagraphs( document, tokens, diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts index 6a410fc..c356ab4 100644 --- a/packages/docx-engine/src/pandoc-converter.ts +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions { referenceCacheSize?: number; } +export interface PandocDocxConversionDiagnostics { + outcome: string; + exitCode: number | null; + stderr: string; +} + export class PandocDocxConversionError extends Error { constructor( readonly code: Extract< @@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error { | "DOCX_OUTPUT_INVALID" >, message: string, - options?: ErrorOptions + options?: ErrorOptions & { + diagnostics?: PandocDocxConversionDiagnostics; + } ) { super(message, options); this.name = "PandocDocxConversionError"; + this.diagnostics = options?.diagnostics; } + + readonly diagnostics: PandocDocxConversionDiagnostics | undefined; +} + +function processDiagnostics( + result: Awaited> +): PandocDocxConversionDiagnostics { + return { + outcome: result.outcome, + exitCode: result.exitCode, + stderr: result.stderr.trim().slice(-16 * 1024) + }; } function ensureOutputLimit(value: number | undefined) { @@ -172,7 +193,7 @@ function pandocArguments(paths: { return [ paths.markdown, "--from", - "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html", + "commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html", "--to", "docx", "--standalone", @@ -351,7 +372,8 @@ export class PandocDocxConverter { processResult.outcome === "not-found" ? "DOCX_RUNTIME_NOT_FOUND" : "DOCX_GENERATION_FAILED", - "Pandoc 未能生成 DOCX" + "Pandoc 未能生成 DOCX", + { diagnostics: processDiagnostics(processResult) } ); } diff --git a/packages/docx-engine/src/pandoc-media.ts b/packages/docx-engine/src/pandoc-media.ts index 07e7929..801bd3b 100644 --- a/packages/docx-engine/src/pandoc-media.ts +++ b/packages/docx-engine/src/pandoc-media.ts @@ -2,8 +2,8 @@ import { DOCX_MEDIA_CSS_DPI, MAXIMUM_DOCX_MEDIA_BYTES, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, + MAXIMUM_DOCX_MEDIA_COUNT, MAXIMUM_DOCX_MEDIA_PIXELS, - MAXIMUM_DOCX_RESOURCE_COUNT, MAXIMUM_DOCX_TOTAL_MEDIA_BYTES, type DocxMediaKind, type DocxDocumentLayoutPlan, @@ -101,7 +101,7 @@ export function preparePandocMedia( if (media.echartsErrors.length || media.mermaidErrors.length) { throw new Error("DOCX 图表渲染存在错误,已中止转换"); } - if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) { + if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) { throw new Error("DOCX 媒体数量超过限制"); } diff --git a/packages/docx-engine/src/section-transform.ts b/packages/docx-engine/src/section-transform.ts index 81620ce..c83daf8 100644 --- a/packages/docx-engine/src/section-transform.ts +++ b/packages/docx-engine/src/section-transform.ts @@ -139,6 +139,19 @@ export function transformSettingsXml( ) { const document = parseXmlPart(content, "word/settings.xml"); const settings = document.documentElement!; + const compatibility = + firstDirectChild(settings, WORD_NAMESPACE, "compat") ?? + appendElement(settings, WORD_NAMESPACE, "w:compat"); + removeDirectChildren( + compatibility, + WORD_NAMESPACE, + "doNotExpandShiftReturn" + ); + appendElement( + compatibility, + WORD_NAMESPACE, + "w:doNotExpandShiftReturn" + ); removeDirectChildren( settings, WORD_NAMESPACE, diff --git a/packages/docx-engine/src/styles-transform.ts b/packages/docx-engine/src/styles-transform.ts index 2b8a146..bc6b7cc 100644 --- a/packages/docx-engine/src/styles-transform.ts +++ b/packages/docx-engine/src/styles-transform.ts @@ -467,8 +467,13 @@ function applyTokenParagraphStyle( (token.paddingPt?.right ?? 0) > 0 || (token.borders?.right?.widthPt ?? 0) > 0 ? (token.rightIndentPt ?? 0) + - (token.paddingPt?.right ?? 0) + - (token.borders?.right?.widthPt ?? 0) + // 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。 + // 若再把 padding/border 写进 w:ind,Word/WPS 会重复扣减 + // 内容宽度,使本可容纳的等宽代码行在末尾额外回流。 + (horizontalPaddingThroughBorderSpace && token.borders?.right + ? 0 + : (token.paddingPt?.right ?? 0) + + (token.borders?.right?.widthPt ?? 0)) : undefined; if ( token.firstLineIndentPt !== undefined || @@ -1191,12 +1196,23 @@ function applyTokenStyles( binding.styleId === "ImageCaption" ? { ...entry.style, alignment: "center" as const } : entry.style; - applyTokenParagraphStyle( - ensureDirectElement( - target, + const paragraphProperties = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:pPr" + ); + // SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时, + // normalizer 会省略 backgroundColor;这里必须先清除基础模板的 + // 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。 + if (binding.styleId === "SourceCode") { + removeDirectChildren( + paragraphProperties, WORD_NAMESPACE, - "w:pPr" - ), + "shd" + ); + } + applyTokenParagraphStyle( + paragraphProperties, paragraphToken, fallbackFontSizeForSlot(slot, fallback), binding.styleId === "SourceCode" @@ -1205,12 +1221,20 @@ function applyTokenStyles( const runToken = binding.styleId === "SourceCode" ? slots.get("code-block-text")?.style ?? entry.style : entry.style; - applyTokenRunStyle( - ensureDirectElement( - target, + const runProperties = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:rPr" + ); + if (binding.styleId === "SourceCode") { + removeDirectChildren( + runProperties, WORD_NAMESPACE, - "w:rPr" - ), + "shd" + ); + } + applyTokenRunStyle( + runProperties, runToken, fallbackFontsForSlot(slot, fallback), binding.styleId === "SourceCode" || diff --git a/packages/docx-engine/src/token-style-map.ts b/packages/docx-engine/src/token-style-map.ts index 3ece178..77c32b4 100644 --- a/packages/docx-engine/src/token-style-map.ts +++ b/packages/docx-engine/src/token-style-map.ts @@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly< export function resolvePandocSyntaxStyleSlot( styleId: string, - text = "" + text = "", + context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {} ): DocxStyleSlotName | "code-block-text" | undefined { if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) { return undefined; } + if (context.htmlTagName) { + return "code-token-name"; + } + if (context.objectPropertyKey) { + return "code-token-attribute"; + } if ( styleId === "NormalTok" || styleId === "OtherTok" || - (styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text)) + ((styleId === "FunctionTok" || styleId === "DataTypeTok") && + /^[\p{P}\p{S}\s]+$/u.test(text)) ) { - return styleId === "FunctionTok" + return styleId === "FunctionTok" || styleId === "DataTypeTok" ? "code-token-punctuation" : "code-block-text"; } diff --git a/packages/docx-engine/tests/document-structure-transform.test.ts b/packages/docx-engine/tests/document-structure-transform.test.ts index a9c9ef9..4979cc9 100644 --- a/packages/docx-engine/tests/document-structure-transform.test.ts +++ b/packages/docx-engine/tests/document-structure-transform.test.ts @@ -22,6 +22,8 @@ const drawing = "http://schemas.openxmlformats.org/drawingml/2006/main"; const picture = "http://schemas.openxmlformats.org/drawingml/2006/picture"; +const math = + "http://schemas.openxmlformats.org/officeDocument/2006/math"; function generatedFixture() { const baseline = readReferenceDocxPackage( @@ -38,6 +40,14 @@ function generatedFixture() { `CONTAINER_END` + `SECTION_BREAK` + `正文标题` + + `` + + `<` + + `br` + + `>` + + `` + + ` method` + + `:` + + ` "POST"` + `` + `` + `` + @@ -183,6 +193,46 @@ const tokens: DocxThemeTokenSet = { } } }, + { + slot: "code-block-text", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Face"], + fontSizePt: 9, + color: "#24292e" + } + }, + { + slot: "code-token-punctuation", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Face"], + fontSizePt: 9, + color: "#7a7a7a" + } + }, + { + slot: "code-token-name", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Face"], + fontSizePt: 9, + color: "#22863a" + } + }, + { + slot: "code-token-attribute", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Face"], + fontSizePt: 9, + color: "#005cc5" + } + }, { slot: "table-header", source: "computed-css", @@ -292,7 +342,12 @@ describe("生成 DOCX 结构收口", () => { documentXml.match( /]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu ) - ).toHaveLength(2); + ).toHaveLength(1); + expect( + documentXml.match( + /]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu + ) + ).toHaveLength(1); expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2); expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2); expect( @@ -304,6 +359,12 @@ describe("生成 DOCX 结构收口", () => { expect(tableParagraph).toContain(''); expect(tableParagraph).toContain(''); expect(documentXml).toContain(""); + expect(documentXml).toMatch( + /[\s\S]*?[\s\S]*?<<\/w:t>[\s\S]*?[\s\S]*?br<\/w:t>[\s\S]*?[\s\S]*?><\/w:t>[\s\S]*?<\/w:p>/u + ); + expect(documentXml).toMatch( + /[\s\S]*?[\s\S]*?[\s\S]*? method<\/w:t>[\s\S]*?<\/w:p>/u + ); expect(documentXml).toContain( '' ); @@ -389,6 +450,98 @@ describe("生成 DOCX 结构收口", () => { ); }); + it("含行内代码的实测行距允许 Word 扩展行盒", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + "", + '前缀inline后缀' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + tokens, + [], + { + tables: [], + textBlocks: [{ + ordinal: 1, + text: "前缀inline后缀", + letterSpacingPt: 0, + linePitchPt: 11.15, + lineBreakOffsets: [] + }] + } + ); + const outputXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(outputXml).toMatch( + /w:line="223" w:lineRule="atLeast"[\s\S]*?前缀<\/w:t>[\s\S]*?/u + ); + }); + + it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + "", + '前缀inline' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + tokens + ); + const outputXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(outputXml).toMatch( + /w:line="312" w:lineRule="atLeast"[\s\S]*?/u + ); + }); + + it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + `xmlns:r="${relationships}"`, + `xmlns:r="${relationships}" xmlns:m="${math}"` + ).replace( + "B", + '≥0.80' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + tokens + ); + const outputXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(outputXml).toMatch( + /[\s\S]*?[\s\S]*?≥0\.80<\/m:t>[\s\S]*?<\/m:oMath><\/w:rPr>\u200B<\/w:t><\/w:r>/u + ); + }); + it("为右对齐盒模型保留 CSS 右侧内容内缩", () => { const fixture = readGeneratedDocxPackage(generatedFixture()); const documentXml = decoder.decode( @@ -508,6 +661,7 @@ describe("生成 DOCX 结构收口", () => { '' ); expect(documentXml).toContain(''); + expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect( @@ -525,6 +679,112 @@ describe("生成 DOCX 结构收口", () => { expect(documentXml).toContain(''); }); + it("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => { + const result = finalizeGeneratedDocxStructure( + generatedFixture(), + plan, + tokens, + [], + { + tables: [ + { + ordinal: 1, + widthPercent: 300, + leftOffsetPercent: 0, + columnWidthPercents: [40, 10, 10, 10, 10, 20], + rows: [] + } + ] + } + ); + const documentXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(documentXml).toContain( + '' + ); + expect(documentXml).toContain(''); + expect(documentXml).toContain(''); + }); + + it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + '正文标题', + '正文标题' + + '⭐⭐⭐⭐' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + tokens, + [], + { + tables: [], + emojiRuns: [ + { ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" } + ] + } + ); + const finalizedXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(finalizedXml).toMatch( + /<\/w:rPr>⭐⭐⭐⭐<\/w:t><\/w:r>/u + ); + }); + + it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => { + const paddedTokens = { + ...tokens, + slots: tokens.slots.map((slot) => + slot.slot === "table-cell" + ? { + ...slot, + style: { + ...slot.style, + paddingPt: { top: 3, right: 6, bottom: 3, left: 6 } + } + } + : slot + ) + } satisfies DocxThemeTokenSet; + const result = finalizeGeneratedDocxStructure( + generatedFixture(), + plan, + paddedTokens, + [], + { + tables: [{ + ordinal: 1, + widthPercent: 90, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70], + rows: [] + }] + } + ); + const documentXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(documentXml).toContain( + '' + ); + }); + it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), @@ -882,6 +1142,36 @@ describe("生成 DOCX 结构收口", () => { ); }); + it("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => { + const source = readGeneratedDocxPackage(generatedFixture()); + const entries = new Map(source.entries); + const documentXml = decoder.decode(entries.get("word/document.xml")!); + entries.set( + "word/document.xml", + encoder.encode(documentXml.replace( + "", + `Caution` + + `警告正文` + + `` + )) + ); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(entries), + plan, + tokens + ); + const finalizedXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(finalizedXml).toMatch( + /[\s\S]*?警告正文<\/w:t>/u + ); + }); + it("按最终页面内容区重算右置百分比容器缩进", () => { const relativeTokens: DocxThemeTokenSet = { ...tokens, diff --git a/packages/docx-engine/tests/pandoc-converter.test.ts b/packages/docx-engine/tests/pandoc-converter.test.ts index 98c92b1..89aeec8 100644 --- a/packages/docx-engine/tests/pandoc-converter.test.ts +++ b/packages/docx-engine/tests/pandoc-converter.test.ts @@ -127,7 +127,7 @@ describe("Pandoc DOCX 转换器", () => { expect(arguments_).toContain("--data-dir"); expect(arguments_).toContain("--resource-path"); expect(argumentAfter(arguments_, "--from")).toBe( - "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html" + "commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html" ); expect( options.env?.MD_TO_PDF_DOCX_MEDIA_MAP @@ -219,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => { code: "DOCX_OUTPUT_INVALID" } satisfies Partial); }); + + it("进程失败时保留内部诊断但保持稳定的对外错误", async () => { + const failedRunner = vi.fn(async () => ({ + outcome: "completed", + exitCode: 64, + stdout: new Uint8Array(), + stderr: "pandoc: 媒体映射失败" + })); + + await expect( + new PandocDocxConverter({ + runtime, + runner: failedRunner, + temporaryRoot + }).convert(input()) + ).rejects.toMatchObject({ + code: "DOCX_GENERATION_FAILED", + message: "Pandoc 未能生成 DOCX", + diagnostics: { + outcome: "completed", + exitCode: 64, + stderr: "pandoc: 媒体映射失败" + } + } satisfies Partial); + }); }); diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index fa54c49..24ad828 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -404,11 +404,12 @@ describe("动态 reference.docx", () => { const sourceCodeStyle = stylesXml.match( /]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u )?.[0]; + expect(sourceCodeStyle).not.toContain("' ); expect(sourceCodeStyle).toMatch( - /]*w:left="195"[^>]*w:right="75"[^>]*\/>/u + /]*w:left="195"[^>]*w:right="0"[^>]*\/>/u ); expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"'); expect(stylesXml).toMatch( @@ -451,6 +452,49 @@ describe("动态 reference.docx", () => { ); }); + it("按代码容器令牌清除或写入 SourceCode 段落底纹", () => { + const createWithBackground = (backgroundColor?: string) => { + const result = createDynamicReferenceDocx( + createBaselineReference(), + { + ...createOptions(defaultExportConfig), + themeTokens: themeTokens("f".repeat(64), [ + { + slot: "code-block", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Consolas"], + fontSizePt: 10, + ...(backgroundColor ? { backgroundColor } : {}) + } + }, + { + slot: "code-block-text", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Consolas"], + fontSizePt: 10 + } + } + ]) + } + ); + const stylesXml = decoder.decode( + unzipSync(result.content)["word/styles.xml"] + ); + return stylesXml.match( + /]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u + )?.[0]; + }; + + expect(createWithBackground()).not.toContain("' + ); + }); + it("生成横向、自定义页边距和奇偶首页页眉页脚", () => { const exportConfig: ExportConfig = { ...defaultExportConfig, @@ -506,6 +550,7 @@ describe("动态 reference.docx", () => { expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3); expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3); expect(settingsXml).toContain(" { it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => { expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"')) .toBe("code-token-attribute"); + expect(resolvePandocSyntaxStyleSlot("DataTypeTok", "<")) + .toBe("code-token-punctuation"); + expect( + resolvePandocSyntaxStyleSlot( + "KeywordTok", + "section", + { htmlTagName: true } + ) + ).toBe("code-token-name"); expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"')) .toBe("code-token-string"); expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":")) @@ -97,6 +106,13 @@ describe("DOCX 令牌样式映射", () => { .toBe("code-token-title"); expect(resolvePandocSyntaxStyleSlot("NormalTok", " ")) .toBe("code-block-text"); + expect( + resolvePandocSyntaxStyleSlot( + "NormalTok", + " method", + { objectPropertyKey: true } + ) + ).toBe("code-token-attribute"); expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x")) .toBeUndefined(); }); diff --git a/packages/docx-theme-engine/src/collector.ts b/packages/docx-theme-engine/src/collector.ts index a0607c0..d1291c1 100644 --- a/packages/docx-theme-engine/src/collector.ts +++ b/packages/docx-theme-engine/src/collector.ts @@ -41,6 +41,9 @@ export function readDocxComputedStyle( fontStyle: style.fontStyle, color: style.color, backgroundColor: style.backgroundColor, + ...(style.backgroundImage + ? { backgroundImage: style.backgroundImage } + : {}), lineHeight: style.lineHeight, letterSpacing: style.letterSpacing, textAlign: style.textAlign, diff --git a/packages/docx-theme-engine/src/css-values.ts b/packages/docx-theme-engine/src/css-values.ts index ba460d0..aa12a77 100644 --- a/packages/docx-theme-engine/src/css-values.ts +++ b/packages/docx-theme-engine/src/css-values.ts @@ -79,6 +79,25 @@ export function parseCssColor( ); } +export function parseCssLinearGradientFallbackColor( + input: string +): string | undefined { + const value = input.trim(); + if (!/^(?:repeating-)?linear-gradient\(/iu.test(value)) { + return undefined; + } + const colorValues = value.match( + /rgba?\([^)]*\)|#[\da-f]{3,8}/giu + ) ?? []; + for (const colorValue of colorValues) { + const parsed = parseCssColor(colorValue); + if (parsed) { + return parsed; + } + } + return undefined; +} + function parseAlpha(value: string | undefined): number | undefined { if (value === undefined) { return undefined; diff --git a/packages/docx-theme-engine/src/normalizer.ts b/packages/docx-theme-engine/src/normalizer.ts index c7d71cd..89944b7 100644 --- a/packages/docx-theme-engine/src/normalizer.ts +++ b/packages/docx-theme-engine/src/normalizer.ts @@ -7,6 +7,7 @@ import { millimetersToPoints, parseCssBorder, parseCssColor, + parseCssLinearGradientFallbackColor, parseCssFontFamilies, parseCssLengthToPt, roundDocxValue @@ -292,6 +293,20 @@ function normalizeComputedSlot( ); if (background) { style.backgroundColor = background; + } else if (computed.backgroundImage) { + const gradientBackground = parseCssLinearGradientFallbackColor( + computed.backgroundImage + ); + if (gradientBackground) { + style.backgroundColor = gradientBackground; + diagnostic(context, { + severity: "info", + code: "layout-approximated", + property: "background-image", + message: "CSS 线性渐变已使用首个非透明色近似为 Word 段落底纹" + }); + context.approximate = true; + } } const letterSpacing = computed.letterSpacing.toLowerCase() === "normal" ? 0 diff --git a/packages/docx-theme-engine/src/probe.ts b/packages/docx-theme-engine/src/probe.ts index 7c2f6d8..2c44f09 100644 --- a/packages/docx-theme-engine/src/probe.ts +++ b/packages/docx-theme-engine/src/probe.ts @@ -35,7 +35,7 @@ export function createDocxStyleProbeMarkup(): string {
  • 无序列表
  1. 有序列表

引用内容

-
const key = "value"; true title 1 // comment Array @meta value /x/ {}
+
const key = "value"; true title 1 // comment Array @meta value /x/ {} section
diff --git a/packages/docx-theme-engine/src/runtime.ts b/packages/docx-theme-engine/src/runtime.ts index 05e5f13..46b3825 100644 --- a/packages/docx-theme-engine/src/runtime.ts +++ b/packages/docx-theme-engine/src/runtime.ts @@ -55,6 +55,7 @@ const computedProperties = [ "fontStyle", "color", "backgroundColor", + "backgroundImage", "lineHeight", "letterSpacing", "textAlign", diff --git a/packages/docx-theme-engine/src/slots.ts b/packages/docx-theme-engine/src/slots.ts index 15949b6..783cf17 100644 --- a/packages/docx-theme-engine/src/slots.ts +++ b/packages/docx-theme-engine/src/slots.ts @@ -35,6 +35,7 @@ export const DOCX_STYLE_SLOT_NAMES = [ "code-token-operator", "code-token-regexp", "code-token-punctuation", + "code-token-name", "table", "table-header", "table-cell", @@ -153,6 +154,7 @@ const kinds: Record = { "code-token-operator": "inline", "code-token-regexp": "inline", "code-token-punctuation": "inline", + "code-token-name": "inline", table: "table", "table-header": "table", "table-cell": "table", diff --git a/packages/docx-theme-engine/src/snapshot.ts b/packages/docx-theme-engine/src/snapshot.ts index 45f0056..22e8b2a 100644 --- a/packages/docx-theme-engine/src/snapshot.ts +++ b/packages/docx-theme-engine/src/snapshot.ts @@ -10,6 +10,7 @@ export const docxComputedStyleSchema = z.object({ fontStyle: cssValueSchema, color: cssValueSchema, backgroundColor: cssValueSchema, + backgroundImage: cssValueSchema.optional(), lineHeight: cssValueSchema, letterSpacing: cssValueSchema, textAlign: cssValueSchema, diff --git a/packages/docx-theme-engine/tests/normalizer.test.ts b/packages/docx-theme-engine/tests/normalizer.test.ts index 552bd4f..ed0a908 100644 --- a/packages/docx-theme-engine/tests/normalizer.test.ts +++ b/packages/docx-theme-engine/tests/normalizer.test.ts @@ -4,6 +4,7 @@ import { millimetersToPoints, parseCssBorder, parseCssColor, + parseCssLinearGradientFallbackColor, parseCssFontFamilies, parseCssLengthToPt, resolveDocxThemeTokens, @@ -106,6 +107,12 @@ describe("CSS 到 Word 基础值归一化", () => { expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined(); expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080"); expect(parseCssColor("#00000080")).toBe("#7f7f7f"); + expect(parseCssLinearGradientFallbackColor( + "linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))" + )).toBe("#eff6ff"); + expect(parseCssLinearGradientFallbackColor( + "radial-gradient(rgb(239, 246, 255), transparent)" + )).toBeUndefined(); expect( parseCssFontFamilies( '"Source Han Serif SC", "Microsoft YaHei", serif' @@ -185,6 +192,32 @@ describe("DOCX 主题令牌归一化", () => { expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); }); + it("将线性渐变首个实色近似为 Word 段落底纹", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot({ + "heading-2": { + backgroundColor: "rgba(0, 0, 0, 0)", + backgroundImage: + "linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))" + } + }), + config: { + mode: "auto", + basePreset: "general", + overrides: {}, + legacyPreset: false + } + }); + + expect(findSlot(tokens, "heading-2").style.backgroundColor) + .toBe("#eff6ff"); + expect(tokens.diagnostics).toContainEqual(expect.objectContaining({ + slot: "heading-2", + code: "layout-approximated", + property: "background-image" + })); + }); + it("按 border-box 文档的内容区宽度归一化全宽表格", () => { const tokens = resolveDocxThemeTokens({ snapshot: createSnapshot({ diff --git a/packages/preview-engine/src/code-block-pagination.ts b/packages/preview-engine/src/code-block-pagination.ts index 04076fb..41ab305 100644 --- a/packages/preview-engine/src/code-block-pagination.ts +++ b/packages/preview-engine/src/code-block-pagination.ts @@ -3,6 +3,7 @@ const CHUNK_CLASS = "md-code-pagination-chunk"; const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position"; const LINE_GROUP_CLASS = "md-code-line-group"; const LINE_CLASS = "md-code-line"; +const LINE_INDENT_CLASS = "md-code-line-indent"; function splitNodeIntoLines(node: Node): Node[][] { const documentRef = node.ownerDocument; @@ -47,6 +48,56 @@ function lineHasContent(nodes: Node[]) { return nodes.some((node) => (node.textContent ?? "").length > 0); } +function stabilizeLeadingWhitespace( + documentRef: Document, + nodes: Node[] +) { + let indent = ""; + let index = 0; + for (; index < nodes.length; index += 1) { + const node = nodes[index]; + if (!node || node.nodeType !== node.TEXT_NODE) { + break; + } + const value = node.textContent ?? ""; + const match = value.match(/^[\t ]+/u); + if (!match) { + if (value.length === 0) { + continue; + } + break; + } + indent += match[0]; + const remainder = value.slice(match[0].length); + if (remainder) { + node.textContent = remainder; + break; + } + } + if (!indent) { + return nodes; + } + const indentElement = documentRef.createElement("span"); + indentElement.className = LINE_INDENT_CLASS; + let columns = 0; + for (const character of indent) { + columns = character === "\t" + ? columns + (4 - columns % 4) + : columns + 1; + } + indentElement.dataset.codeIndentColumns = String(columns); + indentElement.style.width = `${columns}ch`; + indentElement.textContent = indent; + return [ + indentElement, + ...nodes.slice(index).filter( + (node) => + node.nodeType !== node.TEXT_NODE || + (node.textContent ?? "").length > 0 + ) + ]; +} + function createLineGroups( documentRef: Document, lines: Node[][] @@ -68,7 +119,12 @@ function createLineGroups( ) { const line = documentRef.createElement("span"); line.className = LINE_CLASS; - line.append(...(lines[lineIndex] ?? [])); + line.append( + ...stabilizeLeadingWhitespace( + documentRef, + lines[lineIndex] ?? [] + ) + ); group.append(line); } diff --git a/packages/preview-engine/src/docx-media-runtime.ts b/packages/preview-engine/src/docx-media-runtime.ts index 910c51a..1df5e03 100644 --- a/packages/preview-engine/src/docx-media-runtime.ts +++ b/packages/preview-engine/src/docx-media-runtime.ts @@ -1,13 +1,14 @@ import { DOCX_MEDIA_RASTER_SCALE, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, + MAXIMUM_DOCX_MEDIA_COUNT, MAXIMUM_DOCX_MEDIA_PIXELS, - MAXIMUM_DOCX_RESOURCE_COUNT, type DocxMediaCapturePlan, type DocxMediaCaptureTarget, type DocxMediaAlignment, type DocxMediaKind, type DocxDocumentLayoutPlan, + type DocxEmojiRunLayout, type DocxInlineCodeLayout, type DocxListItemLayout, type DocxTableLayout, @@ -15,12 +16,19 @@ import { type PagedDocumentPayload } from "@md-to-pdf/core"; import { PagedDocumentRuntime } from "./paged-document-runtime.js"; +import { stabilizePagedTableColumns } from "./paged-table-handler.js"; export interface DocxMediaRenderDimensions { contentWidthPx: number; contentHeightPx: number; } +export type DocxTableGeometry = Pick< + DocxTableLayout, + "ordinal" | "widthPercent" | "leftOffsetPercent" | + "columnWidthPercents" +>; + declare global { interface Window { __mdToPdfRenderDocxMedia?: ( @@ -157,6 +165,23 @@ function average(values: readonly number[], fallback: number) { } function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) { + const stabilizedColumns = Array.from( + table.querySelectorAll( + ":scope > colgroup[data-stabilized-columns=\"true\"] > col" + ) + ); + const stabilizedWidths = stabilizedColumns.map((column) => + Number(column.dataset.stabilizedWidthPx) + ); + if ( + stabilizedWidths.length > 0 && + stabilizedWidths.every((width) => Number.isFinite(width) && width > 0) + ) { + // 这组轨道来自分页前的完整自动布局。后续为稳定 Paged.js 克隆 + // 写入的 col/cell 辅助宽度可能反过来扰动源表二次测量,DOCX 必须 + // 使用最初与 Chromium 分页基线一致的列轨,而不是辅助样式后的值。 + return stabilizedWidths; + } const rows = Array.from(table.rows); const columnCount = rows.reduce( (maximum, row) => @@ -255,7 +280,7 @@ function cellAlignment(value: string): "left" | "center" | "right" | "justify" { const DOCX_TEXT_BLOCK_SELECTOR = "h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " + - ".doc-classification, .doc-issue-row, .doc-printing-row, " + + ".md-alert-text, .doc-classification, .doc-issue-row, .doc-printing-row, " + ".doc-briefing-meta"; function textNodeCharacters(root: HTMLElement) { @@ -293,9 +318,19 @@ function characterRect( return rect.width > 0 && rect.height > 0 ? rect : undefined; } +function hasDistributedLastLine( + _element: HTMLElement, + computed: CSSStyleDeclaration, + _lastLine: readonly DOMRect[] +) { + const textAlignLast = computed.textAlignLast.trim().toLowerCase(); + return textAlignLast === "justify" || textAlignLast === "distribute"; +} + export function collectDocxTextBlockLayouts( article: HTMLElement ): DocxTextBlockLayout[] { + const articleRect = article.getBoundingClientRect(); const candidates = Array.from( article.querySelectorAll(DOCX_TEXT_BLOCK_SELECTOR) ).filter((element) => { @@ -350,39 +385,33 @@ export function collectDocxTextBlockLayouts( } const text = output.join("").trim(); const computed = getComputedStyle(element); + const alert = element.closest(".md-alert"); + const alertRole = element.matches(".md-alert-text") + ? "title" as const + : alert + ? "body" as const + : undefined; + const alertRect = alert?.getBoundingClientRect(); + const alertComputed = alert ? getComputedStyle(alert) : undefined; + const alertLeft = alertRect && alertComputed + ? alertRect.left + + Number.parseFloat(alertComputed.borderLeftWidth || "0") + + Number.parseFloat(alertComputed.paddingLeft || "0") + : undefined; + const alertRight = alertRect && alertComputed + ? alertRect.right - + Number.parseFloat(alertComputed.borderRightWidth || "0") - + Number.parseFloat(alertComputed.paddingRight || "0") + : undefined; const letterSpacingPx = computed.letterSpacing === "normal" ? 0 : Number.parseFloat(computed.letterSpacing); - const elementRect = element.getBoundingClientRect(); - const contentWidth = Math.max( - 0, - elementRect.width - - Number.parseFloat(computed.paddingLeft || "0") - - Number.parseFloat(computed.paddingRight || "0") - - Number.parseFloat(computed.borderLeftWidth || "0") - - Number.parseFloat(computed.borderRightWidth || "0") - ); const lastLine = lineCharacterRects.at(-1) ?? []; - const sortedLastLine = [...lastLine].sort( - (left, right) => left.left - right.left + const distributed = hasDistributedLastLine( + element, + computed, + lastLine ); - const lastLineWidth = sortedLastLine.length > 0 - ? sortedLastLine.at(-1)!.right - sortedLastLine[0]!.left - : 0; - const maximumGap = sortedLastLine.slice(1).reduce( - (largest, rect, rectIndex) => Math.max( - largest, - rect.left - sortedLastLine[rectIndex]!.right - ), - 0 - ); - const fontSizePx = Number.parseFloat(computed.fontSize); - const distributed = - sortedLastLine.length > 1 && - contentWidth > 0 && - lastLineWidth / contentWidth >= 0.85 && - Number.isFinite(fontSizePx) && - maximumGap >= fontSizePx * 0.5; const lineTops = lineCharacterRects .map((rects) => rects.length > 0 ? Math.min(...rects.map((rect) => rect.top)) @@ -401,6 +430,19 @@ export function collectDocxTextBlockLayouts( letterSpacingPt: Number.isFinite(letterSpacingPx) ? letterSpacingPx * 0.75 : 0, + ...(alertRole && alertLeft !== undefined && alertRight !== undefined + ? { + alertRole, + fontSizePt: cssPixelsToPoints(computed.fontSize), + bold: computed.fontWeight === "bold" || + Number.parseInt(computed.fontWeight, 10) >= 600, + italic: computed.fontStyle === "italic" || + computed.fontStyle === "oblique", + color: cssColor(computed.color) ?? "#000000", + leftIndentPt: Math.max(0, (alertLeft - articleRect.left) * 0.75), + rightIndentPt: Math.max(0, (articleRect.right - alertRight) * 0.75) + } + : {}), alignment: distributed ? "distribute" as const : cellAlignment(computed.textAlign), @@ -447,6 +489,18 @@ export function collectDocxListItemLayouts( if (!rect) { return []; } + const visibleRects = characters.flatMap((character) => { + const characterBounds = characterRect(range, character); + return characterBounds ? [characterBounds] : []; + }); + const lastLineTop = visibleRects.length > 0 + ? Math.max(...visibleRects.map((bounds) => bounds.top)) + : 0; + const lastLine = visibleRects.filter( + (bounds) => Math.abs(bounds.top - lastLineTop) <= 1 + ); + const computed = getComputedStyle(item); + const distributed = hasDistributedLastLine(item, computed, lastLine); let listDepth = -1; for ( let ancestor: Element | null = item.parentElement; @@ -464,7 +518,8 @@ export function collectDocxListItemLayouts( textStartPt: Math.max( 0, (rect.left - articleLeft) * 0.75 - ) + ), + ...(distributed ? { alignment: "distribute" as const } : {}) }]; } ); @@ -475,6 +530,86 @@ function cssPixelsToPoints(value: string) { return Number.isFinite(pixels) ? pixels * 0.75 : 0; } +const EMOJI_ONLY_TEXT = /^(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\u200d|\ufe0f|\s)+$/u; + +function sampledEmojiColor(text: string, computed: CSSStyleDeclaration) { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + return undefined; + } + context.font = computed.font; + const metrics = context.measureText(text); + const fontSize = Math.max(12, Number.parseFloat(computed.fontSize) || 16); + canvas.width = Math.max(1, Math.ceil(metrics.width + fontSize)); + canvas.height = Math.max(1, Math.ceil(fontSize * 2)); + const renderContext = canvas.getContext("2d", { willReadFrequently: true }); + if (!renderContext) { + return undefined; + } + renderContext.clearRect(0, 0, canvas.width, canvas.height); + renderContext.font = computed.font; + renderContext.textBaseline = "middle"; + renderContext.fillStyle = computed.color; + renderContext.fillText(text, fontSize / 2, canvas.height / 2); + const pixels = renderContext.getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; + const histogram = new Map(); + for (let index = 0; index < pixels.length; index += 4) { + const alpha = pixels[index + 3] ?? 0; + if (alpha < 96) { + continue; + } + const red = pixels[index] ?? 0; + const green = pixels[index + 1] ?? 0; + const blue = pixels[index + 2] ?? 0; + if (Math.max(red, green, blue) - Math.min(red, green, blue) < 16) { + continue; + } + const key = [red, green, blue] + .map((value) => Math.round(value / 8) * 8) + .join(","); + histogram.set(key, (histogram.get(key) ?? 0) + alpha); + } + const dominant = Array.from(histogram.entries()).sort( + ([, left], [, right]) => right - left + )[0]?.[0]; + if (!dominant) { + return undefined; + } + const channels = dominant.split(",").map(Number); + if (channels.length !== 3 || channels.some((value) => !Number.isFinite(value))) { + return undefined; + } + return `#${channels + .map((value) => Math.min(255, value).toString(16).padStart(2, "0")) + .join("")}`; +} + +export function collectDocxEmojiRunLayouts( + article: HTMLElement +): DocxEmojiRunLayout[] { + const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT); + const layouts: DocxEmojiRunLayout[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = (node.textContent ?? "").normalize("NFC").trim(); + const parent = node.parentElement; + if (!text || !parent || !EMOJI_ONLY_TEXT.test(text)) { + continue; + } + const color = sampledEmojiColor(text, getComputedStyle(parent)); + if (!color) { + continue; + } + layouts.push({ ordinal: layouts.length + 1, text, color }); + } + return layouts; +} + export function collectDocxInlineCodeLayouts( article: HTMLElement ): DocxInlineCodeLayout[] { @@ -545,7 +680,7 @@ export function collectDocxDocumentLayoutPlan( ); return { ordinal: index + 1, - widthPercent: Math.min(100, (width / contentWidth) * 100), + widthPercent: Math.min(300, (width / contentWidth) * 100), leftOffsetPercent: Math.max( 0, Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100) @@ -570,11 +705,112 @@ export function collectDocxDocumentLayoutPlan( })) }; }); + const emojiRuns = collectDocxEmojiRunLayouts(article); return { tables, textBlocks: collectDocxTextBlockLayouts(article), listItems: collectDocxListItemLayouts(article), - inlineCodes: collectDocxInlineCodeLayouts(article) + inlineCodes: collectDocxInlineCodeLayouts(article), + ...(emojiRuns.length > 0 ? { emojiRuns } : {}) + }; +} + +export function collectPagedDocxTableGeometries( + root: ParentNode, + dimensions: DocxMediaRenderDimensions +): DocxTableGeometry[] { + const tablesByReference = new Map(); + let anonymousTableIndex = 0; + for (const table of root.querySelectorAll( + ".pagedjs_pages table" + )) { + const reference = table.dataset.ref ?? + `__anonymous_table_${anonymousTableIndex += 1}`; + const tables = tablesByReference.get(reference) ?? []; + tables.push(table); + tablesByReference.set(reference, tables); + } + const geometries: DocxTableGeometry[] = []; + for (const tables of tablesByReference.values()) { + const table = tables.reduce((best, candidate) => { + const visibleArea = (element: HTMLTableElement) => { + const content = element.closest( + ".pagedjs_area, #write" + ); + const contentRect = content?.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); + if (!contentRect) { + return Math.max(0, rect.width) * Math.max(0, rect.height); + } + const width = Math.max( + 0, + Math.min(rect.right, contentRect.right) - + Math.max(rect.left, contentRect.left) + ); + const height = Math.max( + 0, + Math.min(rect.bottom, contentRect.bottom) - + Math.max(rect.top, contentRect.top) + ); + return width * height; + }; + return visibleArea(candidate) > visibleArea(best) + ? candidate + : best; + }); + const content = table.closest( + ".pagedjs_area, #write" + ); + const contentRect = content?.getBoundingClientRect(); + const contentWidth = finitePositive( + contentRect?.width ?? 0, + dimensions.contentWidthPx + ); + const contentLeft = contentRect?.left ?? 0; + const rect = table.getBoundingClientRect(); + const width = finitePositive(rect.width, contentWidth); + const columnWidths = collectTableColumnWidths(table, rect); + const columnTotal = columnWidths.reduce( + (total, value) => total + value, + 0 + ); + geometries.push({ + ordinal: geometries.length + 1, + widthPercent: Math.min(300, (width / contentWidth) * 100), + leftOffsetPercent: Math.max( + 0, + Math.min(100, ((rect.left - contentLeft) / contentWidth) * 100) + ), + columnWidthPercents: columnWidths.map( + (value) => (value / columnTotal) * 100 + ) + }); + } + return geometries; +} + +export function mergePagedTableGeometries( + layout: DocxDocumentLayoutPlan, + pagedGeometries: readonly DocxTableGeometry[] +): DocxDocumentLayoutPlan { + return { + ...layout, + tables: layout.tables.map((table, index) => { + const geometry = pagedGeometries[index]; + if ( + !geometry || + geometry.columnWidthPercents.length !== + table.columnWidthPercents.length + ) { + return table; + } + return { + ...table, + widthPercent: geometry.widthPercent, + leftOffsetPercent: geometry.leftOffsetPercent, + columnWidthPercents: [...geometry.columnWidthPercents] + }; + }) }; } @@ -595,9 +831,9 @@ export function collectDocxMediaCaptureTargets( ].join(",") ) ); - if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) { + if (candidates.length > MAXIMUM_DOCX_MEDIA_COUNT) { throw new Error( - `DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}` + `DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_MEDIA_COUNT}` ); } @@ -658,6 +894,20 @@ export async function renderDocxMediaCapturePlan( payload: PagedDocumentPayload, dimensions: DocxMediaRenderDimensions ): Promise { + const pagedResult = await runtime.render(payload, { + target: "pdf", + // DOCX 只消费分页表格的宽度、偏移和列宽比例;完整行结构与样式随后 + // 从连续 DOM 重新采集。Paged.js 偶发丢失边界行时允许几何降级, + // 正常 PDF 渲染仍保持严格的正文行完整性门禁。 + allowIncompleteTableGeometry: true + }); + if (!pagedResult) { + throw new Error("DOCX 分页布局渲染已取消"); + } + const pagedTableGeometries = collectPagedDocxTableGeometries( + root, + dimensions + ); const renderResult = await runtime.renderContinuous(payload, { geometryCss: createGeometryCss(dimensions), themeMedia: "print" @@ -665,10 +915,24 @@ export async function renderDocxMediaCapturePlan( if (!renderResult) { throw new Error("DOCX 媒体渲染已取消"); } + stabilizePagedTableColumns(root, dimensions.contentHeightPx); + const continuousLayout = collectDocxDocumentLayoutPlan( + root, + dimensions + ); return { targets: collectDocxMediaCaptureTargets(root, dimensions), - echartsErrors: renderResult.echartsErrors, - mermaidErrors: renderResult.mermaidErrors, - documentLayout: collectDocxDocumentLayoutPlan(root, dimensions) + echartsErrors: Array.from(new Set([ + ...pagedResult.echartsErrors, + ...renderResult.echartsErrors + ])), + mermaidErrors: Array.from(new Set([ + ...pagedResult.mermaidErrors, + ...renderResult.mermaidErrors + ])), + documentLayout: mergePagedTableGeometries( + continuousLayout, + pagedTableGeometries + ) }; } diff --git a/packages/preview-engine/src/index.ts b/packages/preview-engine/src/index.ts index dfbb6d6..0c6fd86 100644 --- a/packages/preview-engine/src/index.ts +++ b/packages/preview-engine/src/index.ts @@ -17,5 +17,6 @@ export * from "./paged-page-sequence.js"; export * from "./semantic-cover-fit.js"; export * from "./paged-page-decorations.js"; export * from "./paged-render-target.js"; +export * from "./paged-table-handler.js"; export * from "./pdf-document-links.js"; export * from "./preview-styles.js"; diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts index faab15d..4a0496f 100644 --- a/packages/preview-engine/src/paged-document-runtime.ts +++ b/packages/preview-engine/src/paged-document-runtime.ts @@ -37,7 +37,11 @@ import { renderMermaidDefinitions } from "./mermaid-renderer.js"; import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js"; import type { MermaidOutputMode } from "./mermaid-static-image.js"; import { enablePrintMediaForPreview } from "./preview-styles.js"; -import "./paged-table-handler.js"; +import { + forcePagedTableRowsToNextPage, + missingPagedTableRowIds, + stabilizePagedTableColumns +} from "./paged-table-handler.js"; import { buildPagedMediaCss, continuousDocumentGeometryCss, @@ -61,6 +65,7 @@ export interface PagedDocumentRenderOptions { target: PagedRenderTarget; shouldContinue?: () => boolean; mermaidOutput?: MermaidOutputMode; + allowIncompleteTableGeometry?: boolean; } export interface ContinuousDocumentRenderOptions { @@ -75,6 +80,33 @@ export interface PreviewEngineStyles { echartsCss: string; } +export function protectFittableInlineCodeParagraphs( + root: ParentNode, + pageContentHeightPx: number +) { + if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) { + return 0; + } + let protectedCount = 0; + for (const paragraph of root.querySelectorAll( + "#write > p.md-inline-code-paragraph" + )) { + const height = paragraph.getBoundingClientRect().height; + if (!(height > 0 && height <= pageContentHeightPx)) { + continue; + } + paragraph.style.setProperty("break-inside", "avoid", "important"); + paragraph.style.setProperty( + "page-break-inside", + "avoid", + "important" + ); + paragraph.dataset.mdtpInlineCodeKeepWhole = "true"; + protectedCount += 1; + } + return protectedCount; +} + export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000; function waitForImage( @@ -699,6 +731,7 @@ export class PagedDocumentRuntime { payload.features.includes("echarts") || content.querySelector(".mermaid svg") || content.querySelector("img.md-document-image") || + content.querySelector("table") || content.querySelector('[data-semantic-region="cover"]') ) { const measurement = mountMeasurementContainer( @@ -710,6 +743,14 @@ export class PagedDocumentRuntime { ); try { await documentRef.fonts.ready; + protectFittableInlineCodeParagraphs( + measurement.host, + getPageContentDimensions(payload.exportConfig).height + ); + stabilizePagedTableColumns( + measurement.host, + getPageContentDimensions(payload.exportConfig).height + ); constrainSemanticCoversToPage( measurement.host, getPageContentDimensions(payload.exportConfig).height @@ -816,6 +857,51 @@ export class PagedDocumentRuntime { stylesheets, this.root ); + const maximumTableRowRecoveryAttempts = 3; + for ( + let attempt = 0; + attempt < maximumTableRowRecoveryAttempts; + attempt += 1 + ) { + const missingRowIds = missingPagedTableRowIds( + repaginationSource, + this.root + ); + if (missingRowIds.length === 0) { + break; + } + const recoveredCount = forcePagedTableRowsToNextPage( + repaginationSource, + missingRowIds + ); + if (recoveredCount !== missingRowIds.length) { + throw new Error( + `分页表格行恢复标记不完整:缺失 ${missingRowIds.length} 行,` + + `仅定位 ${recoveredCount} 行` + ); + } + previewer.chunker.destroy(); + previewer.polisher.destroy(); + previewer = new Previewer(); + this.activePreviewer = previewer; + flow = await previewer.preview( + repaginationSource.cloneNode(true) as DocumentFragment, + stylesheets, + this.root + ); + } + const unresolvedTableRowIds = missingPagedTableRowIds( + repaginationSource, + this.root + ); + if ( + unresolvedTableRowIds.length > 0 && + !options.allowIncompleteTableGeometry + ) { + throw new Error( + `分页后表格正文行缺失:${unresolvedTableRowIds.join(", ")}` + ); + } const mediaIds = getMediaBackfillIdsInDocumentOrder( repaginationSource ); diff --git a/packages/preview-engine/src/paged-preview.ts b/packages/preview-engine/src/paged-preview.ts index 12a30c9..e03cfbf 100644 --- a/packages/preview-engine/src/paged-preview.ts +++ b/packages/preview-engine/src/paged-preview.ts @@ -127,9 +127,21 @@ svg { table-layout: auto; } +#write table[data-stabilized-columns="true"] { + table-layout: fixed !important; +} + #write th, #write td { overflow-wrap: anywhere; + word-break: break-word; +} + +#write th code, +#write td code { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .mermaid { @@ -421,6 +433,15 @@ ${buildFooterCss(config)} widows: 3; } +#write > p.md-inline-code-paragraph { + orphans: 1; + widows: 1; +} + +#write li { + text-align-last: left; +} + #write h1, #write h2, #write h3, @@ -489,6 +510,11 @@ ${buildFooterCss(config)} word-break: inherit; } +#write .md-code-line-indent { + display: inline-block; + white-space: pre; +} + #write table[data-empty-split-table="true"] { display: none; } diff --git a/packages/preview-engine/src/paged-table-handler.ts b/packages/preview-engine/src/paged-table-handler.ts index cf5793b..4872d0d 100644 --- a/packages/preview-engine/src/paged-table-handler.ts +++ b/packages/preview-engine/src/paged-table-handler.ts @@ -9,8 +9,352 @@ interface PagedChunker { source: ParentNode; } -interface PagedHandlerContext { - chunker: PagedChunker; +function measuredColumnWidths(table: HTMLTableElement) { + const rows = Array.from(table.rows); + const columnCount = rows.reduce( + (maximum, row) => Math.max( + maximum, + Array.from(row.cells).reduce( + (total, cell) => total + Math.max(1, cell.colSpan), + 0 + ) + ), + 0 + ); + const tableRect = table.getBoundingClientRect(); + if (columnCount < 1 || tableRect.width <= 0) { + return []; + } + const boundaries = Array.from( + { length: columnCount + 1 }, + () => [] as number[] + ); + boundaries[0]!.push(0); + boundaries[columnCount]!.push(tableRect.width); + for (const row of rows) { + let column = 0; + for (const cell of Array.from(row.cells)) { + const span = Math.max(1, cell.colSpan); + const rect = cell.getBoundingClientRect(); + boundaries[column]?.push( + Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left)) + ); + column = Math.min(columnCount, column + span); + boundaries[column]?.push( + Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left)) + ); + } + } + const resolved = boundaries.map((samples, index) => + samples.length > 0 + ? samples.reduce((sum, value) => sum + value, 0) / samples.length + : tableRect.width * index / columnCount + ); + resolved[0] = 0; + resolved[columnCount] = tableRect.width; + for (let index = 1; index < resolved.length; index += 1) { + resolved[index] = Math.max(resolved[index - 1]! + 0.01, resolved[index]!); + } + return resolved.slice(1).map( + (boundary, index) => boundary - resolved[index]! + ); +} + +function applyColumnWidthWeights( + columns: readonly HTMLTableColElement[], + weights: readonly number[] +) { + for (const [index, column] of columns.entries()) { + column.style.width = `${Math.max(0, weights[index] ?? 0)}px`; + } +} + +function applyCellWidthTracks( + table: HTMLTableElement, + widths: readonly number[] +) { + for (const row of Array.from(table.rows)) { + let columnIndex = 0; + for (const cell of Array.from(row.cells)) { + const span = Math.max(1, cell.colSpan); + const width = widths + .slice(columnIndex, columnIndex + span) + .reduce((sum, value) => sum + value, 0); + if (width > 0) { + cell.style.width = `${width}px`; + } + columnIndex += span; + } + } +} + +export function stabilizePagedTableColumns( + root: ParentNode, + pageContentHeightPx?: number +) { + let stabilizedCount = 0; + for (const [tableIndex, table] of Array.from( + root.querySelectorAll("table") + ).entries()) { + let bodyRowIndex = 0; + if (pageContentHeightPx && pageContentHeightPx > 0) { + for (const row of Array.from(table.rows)) { + const rowHeight = row.getBoundingClientRect().height; + const keepWhole = rowHeight > 0 && rowHeight <= pageContentHeightPx; + if (row.parentElement?.tagName === "TBODY") { + row.dataset.mdtpTableRowId ??= + `table-${tableIndex + 1}-row-${bodyRowIndex + 1}`; + row.dataset.mdtpTableRowKeepWhole = String(keepWhole); + bodyRowIndex += 1; + } + for (const cell of Array.from(row.cells)) { + cell.style.setProperty( + "break-inside", + keepWhole ? "avoid" : "auto", + "important" + ); + cell.style.setProperty( + "page-break-inside", + keepWhole ? "avoid" : "auto", + "important" + ); + } + } + } + if ( + table.dataset.stabilizedColumns === "true" || + table.querySelector(":scope > colgroup") + ) { + continue; + } + const widths = measuredColumnWidths(table); + const total = widths.reduce((sum, value) => sum + value, 0); + if (widths.length === 0 || total <= 0) { + continue; + } + const colgroup = table.ownerDocument.createElement("colgroup"); + colgroup.dataset.stabilizedColumns = "true"; + for (const width of widths) { + const column = table.ownerDocument.createElement("col"); + column.dataset.stabilizedWidthPx = String(width); + colgroup.append(column); + } + table.insertBefore(colgroup, table.firstChild); + table.dataset.stabilizedColumns = "true"; + table.style.tableLayout = "fixed"; + const columns = Array.from(colgroup.children) as HTMLTableColElement[]; + let weights = widths; + applyColumnWidthWeights(columns, weights); + for (let iteration = 0; iteration < 3; iteration += 1) { + const measured = measuredColumnWidths(table); + if ( + measured.length !== widths.length || + measured.some((width) => width <= 0) + ) { + break; + } + weights = weights.map( + (weight, index) => weight * widths[index]! / measured[index]! + ); + applyColumnWidthWeights(columns, weights); + } + applyCellWidthTracks(table, widths); + stabilizedCount += 1; + } + return stabilizedCount; +} + +export function missingPagedTableRowIds( + source: ParentNode, + rendered: ParentNode +) { + const isVisiblePagedRow = (row: HTMLElement) => { + const pageArea = row.closest(".pagedjs_area"); + if (!pageArea) { + return true; + } + const rowRect = row.getBoundingClientRect(); + const areaRect = pageArea.getBoundingClientRect(); + const intersectionWidth = Math.min(rowRect.right, areaRect.right) - + Math.max(rowRect.left, areaRect.left); + const intersectionHeight = Math.min(rowRect.bottom, areaRect.bottom) - + Math.max(rowRect.top, areaRect.top); + return intersectionWidth > 0.5 && intersectionHeight > 0.5; + }; + const renderedIds = new Set( + Array.from(rendered.querySelectorAll( + "[data-mdtp-table-row-id]" + )).filter(isVisiblePagedRow) + .map((row) => row.dataset.mdtpTableRowId) + .filter((id): id is string => Boolean(id)) + ); + return Array.from(source.querySelectorAll( + 'tbody > tr[data-mdtp-table-row-id][data-mdtp-table-row-keep-whole="true"]' + )).map((row) => row.dataset.mdtpTableRowId) + .filter((id): id is string => Boolean(id)) + .filter((id) => !renderedIds.has(id)); +} + +function splitPagedTableBeforeRow(row: HTMLTableRowElement) { + const body = row.parentElement as HTMLTableSectionElement | null; + const table = row.closest("table"); + if ( + !body || + body.tagName !== "TBODY" || + !table || + body.querySelector(":scope > tr") === row + ) { + return table; + } + + const continuation = table.cloneNode(false) as HTMLTableElement; + continuation.dataset.mdtpTableRecoveryContinuation = "true"; + for (const child of Array.from(table.children)) { + if (child.tagName === "COLGROUP" || child.tagName === "THEAD") { + continuation.append(child.cloneNode(true)); + } + } + + const continuationBody = body.cloneNode(false) as HTMLTableSectionElement; + let movingRow: HTMLTableRowElement | null = row; + while (movingRow) { + const nextRow = movingRow.nextElementSibling as HTMLTableRowElement | null; + continuationBody.append(movingRow); + movingRow = nextRow; + } + continuation.append(continuationBody); + + let followingSection = body.nextElementSibling; + while (followingSection) { + const nextSection = followingSection.nextElementSibling; + continuation.append(followingSection); + followingSection = nextSection; + } + table.after(continuation); + return continuation; +} + +export function forcePagedTableRowsToNextPage( + source: ParentNode, + rowIds: readonly string[] +) { + const requested = new Set(rowIds); + let count = 0; + for (const row of source.querySelectorAll( + "tbody > tr[data-mdtp-table-row-id]" + )) { + const id = row.dataset.mdtpTableRowId; + if (!id || !requested.has(id)) { + continue; + } + const table = splitPagedTableBeforeRow(row); + let recoveryTarget: HTMLElement = row; + if (table?.parentNode) { + const previous = table.previousElementSibling as HTMLElement | null; + const marker = previous?.dataset.mdtpTableRecoveryMarker === "true" + ? previous + : table.ownerDocument.createElement("div"); + if (marker !== previous) { + marker.dataset.mdtpTableRecoveryMarker = "true"; + marker.setAttribute("aria-hidden", "true"); + marker.textContent = "\u00a0"; + marker.style.setProperty("height", "1px", "important"); + marker.style.setProperty("line-height", "1px", "important"); + marker.style.setProperty("margin", "0 0 -1px", "important"); + marker.style.setProperty("padding", "0", "important"); + marker.style.setProperty("border", "0", "important"); + marker.style.setProperty("overflow", "hidden", "important"); + marker.style.setProperty("opacity", "0", "important"); + table.parentNode.insertBefore(marker, table); + } + recoveryTarget = marker; + table.dataset.mdtpTableRecovery = "true"; + } + recoveryTarget.style.setProperty("break-before", "page", "important"); + recoveryTarget.style.setProperty( + "page-break-before", + "always", + "important" + ); + row.dataset.mdtpTableRowRecovery = "true"; + count += 1; + } + return count; +} + +export function restorePagedTableStructure( + sourceTable: HTMLTableElement, + renderedTable: HTMLTableElement +) { + restorePagedTableColumns(sourceTable, renderedTable); + + if (!renderedTable.querySelector("thead")) { + const sourceHeader = sourceTable.querySelector("thead"); + if (sourceHeader) { + const firstNonColumnGroup = Array.from(renderedTable.children).find( + (child) => child.tagName !== "COLGROUP" + ) ?? null; + renderedTable.insertBefore( + sourceHeader.cloneNode(true), + firstNonColumnGroup + ); + } + } +} + +export function restorePagedTableColumns( + sourceTable: HTMLTableElement, + renderedTable: HTMLTableElement +) { + const sourceGroups = Array.from( + sourceTable.querySelectorAll( + ":scope > colgroup" + ) + ); + if (sourceGroups.length === 0) { + return; + } + const renderedGroups = Array.from( + renderedTable.querySelectorAll( + ":scope > colgroup" + ) + ); + const matchingStructure = + renderedGroups.length === sourceGroups.length && + renderedGroups.every( + (group, index) => + group.children.length === sourceGroups[index]?.children.length + ); + if (!matchingStructure) { + for (const group of renderedGroups) { + group.remove(); + } + const firstChild = renderedTable.firstChild; + for (const group of sourceGroups) { + renderedTable.insertBefore(group.cloneNode(true), firstChild); + } + } else { + for (const [groupIndex, sourceGroup] of sourceGroups.entries()) { + const renderedGroup = renderedGroups[groupIndex]!; + for (const attribute of Array.from(sourceGroup.attributes)) { + renderedGroup.setAttribute(attribute.name, attribute.value); + } + for (const [columnIndex, sourceColumn] of Array.from( + sourceGroup.children + ).entries()) { + const renderedColumn = renderedGroup.children[ + columnIndex + ] as HTMLTableColElement; + for (const attribute of Array.from(sourceColumn.attributes)) { + renderedColumn.setAttribute(attribute.name, attribute.value); + } + } + } + } + if (sourceTable.dataset.stabilizedColumns === "true") { + renderedTable.dataset.stabilizedColumns = "true"; + renderedTable.style.tableLayout = "fixed"; + } } function elementAncestors( @@ -48,6 +392,21 @@ class RepeatTableHeadersHandler extends Handler { breakToken?: PagedBreakToken ) { this.splitTableRefs = []; + for (const renderedTable of pageElement.querySelectorAll( + "table[data-ref]" + )) { + const ref = renderedTable.getAttribute("data-ref"); + if (!ref) { + continue; + } + const sourceTable = + this.chunker.source.querySelector( + `table[data-ref="${CSS.escape(ref)}"]` + ); + if (sourceTable) { + restorePagedTableColumns(sourceTable, renderedTable); + } + } const element = resolveBreakTokenElement(breakToken?.node); if (!element) { return; @@ -81,6 +440,21 @@ class RepeatTableHeadersHandler extends Handler { } layout(rendered: HTMLElement) { + for (const renderedTable of rendered.querySelectorAll( + "table[data-ref]" + )) { + const ref = renderedTable.getAttribute("data-ref"); + if (!ref) { + continue; + } + const sourceTable = + this.chunker.source.querySelector( + `table[data-ref="${CSS.escape(ref)}"]` + ); + if (sourceTable) { + restorePagedTableColumns(sourceTable, renderedTable); + } + } for (const ref of this.splitTableRefs) { const renderedTable = rendered.querySelector( @@ -101,23 +475,7 @@ class RepeatTableHeadersHandler extends Handler { continue; } - const firstChild = renderedTable.firstChild; - for (const colgroup of sourceTable.querySelectorAll("colgroup")) { - renderedTable.insertBefore( - colgroup.cloneNode(true), - firstChild - ); - } - - if (!renderedTable.querySelector("thead")) { - const sourceHeader = sourceTable.querySelector("thead"); - if (sourceHeader) { - renderedTable.insertBefore( - sourceHeader.cloneNode(true), - renderedTable.firstChild - ); - } - } + restorePagedTableStructure(sourceTable, renderedTable); renderedTable.setAttribute("data-repeated-header", "true"); } diff --git a/packages/preview-engine/tests/code-block-pagination.test.ts b/packages/preview-engine/tests/code-block-pagination.test.ts index 325cc6a..4d9cde6 100644 --- a/packages/preview-engine/tests/code-block-pagination.test.ts +++ b/packages/preview-engine/tests/code-block-pagination.test.ts @@ -98,6 +98,32 @@ describe("代码块分页预处理", () => { ).toBe("return"); }); + it("将高亮代码行首缩进封装为分页稳定节点", () => { + const root = createRoot( + '{\n ' + + '"nested": true\n' + + '}\n' + ); + + prepareCodeBlockPagination(root); + + const lines = getLines(root); + const indent = lines[1]?.querySelector( + ":scope > .md-code-line-indent" + ); + expect(lines.map((line) => line.textContent)).toEqual([ + "{", + ' "nested": true', + "}" + ]); + expect(indent?.textContent).toBe(" "); + expect((indent as HTMLElement | null)?.style.width).toBe("2ch"); + expect((indent as HTMLElement | null)?.dataset.codeIndentColumns) + .toBe("2"); + expect(indent?.nextElementSibling?.classList.contains("hljs-attr")) + .toBe(true); + }); + it("重复调用不会再次包装已经准备的代码块", () => { const root = createRoot("line-1\nline-2\n"); diff --git a/packages/preview-engine/tests/docx-media-runtime.test.ts b/packages/preview-engine/tests/docx-media-runtime.test.ts index 77b771f..b9668f5 100644 --- a/packages/preview-engine/tests/docx-media-runtime.test.ts +++ b/packages/preview-engine/tests/docx-media-runtime.test.ts @@ -2,13 +2,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + collectPagedDocxTableGeometries, collectDocxDocumentLayoutPlan, collectDocxInlineCodeLayouts, collectDocxListItemLayouts, collectDocxTextBlockLayouts, collectDocxMediaCaptureTargets, + forcePagedTableRowsToNextPage, + mergePagedTableGeometries, + missingPagedTableRowIds, + protectFittableInlineCodeParagraphs, removeInheritedPagedSplitJustification, - renderDocxMediaCapturePlan + renderDocxMediaCapturePlan, + restorePagedTableColumns, + restorePagedTableStructure, + stabilizePagedTableColumns } from "../src/index.js"; describe("DOCX 媒体捕获计划", () => { @@ -18,6 +26,10 @@ describe("DOCX 媒体捕获计划", () => { }); it("连续布局使用原始打印媒体主题 CSS", async () => { + const render = vi.fn(async () => ({ + echartsErrors: [], + mermaidErrors: [] + })); const renderContinuous = vi.fn(async () => ({ echartsErrors: [], mermaidErrors: [] @@ -26,7 +38,7 @@ describe("DOCX 媒体捕获计划", () => { root.innerHTML = '
'; await renderDocxMediaCapturePlan( - { renderContinuous } as never, + { render, renderContinuous } as never, root, { articleHtml: '
', @@ -57,6 +69,155 @@ describe("DOCX 媒体捕获计划", () => { expect.anything(), expect.objectContaining({ themeMedia: "print" }) ); + expect(render).toHaveBeenCalledWith( + expect.anything(), + { + target: "pdf", + allowIncompleteTableGeometry: true + } + ); + }); + + it("仅将能完整放入一页的顶层行内代码段落保持为整体", () => { + document.body.innerHTML = ` +
+

短段落

+

超长段落

+
表头
单元格
+

单元格段落

+
+ + `; + const short = document.querySelector("#short")!; + const long = document.querySelector("#long")!; + const cell = document.querySelector("#cell")!; + short.getBoundingClientRect = () => ({ height: 240 }) as DOMRect; + long.getBoundingClientRect = () => ({ height: 1200 }) as DOMRect; + cell.getBoundingClientRect = () => ({ height: 120 }) as DOMRect; + + expect(protectFittableInlineCodeParagraphs(document, 900)).toBe(1); + expect(short.style.getPropertyValue("break-inside")).toBe("avoid"); + expect(short.style.getPropertyPriority("break-inside")).toBe("important"); + expect(short.dataset.mdtpInlineCodeKeepWhole).toBe("true"); + expect(long.style.getPropertyValue("break-inside")).toBe(""); + expect(cell.style.getPropertyValue("break-inside")).toBe(""); + }); + + it("按逻辑表格去重采集分页后的真实列轨", () => { + document.body.innerHTML = ` +
+
+
+ + + + + + +
AB
+
+
+
+
+ + + + + + +
CD
+
+
+
+ `; + const areas = Array.from( + document.querySelectorAll(".pagedjs_area") + ); + const tables = Array.from( + document.querySelectorAll("table") + ); + areas.forEach((area) => { + area.getBoundingClientRect = () => ({ + left: 100, right: 900, top: 0, bottom: 900, + width: 800, height: 900 + }) as DOMRect; + }); + tables.forEach((table, index) => { + table.getBoundingClientRect = () => ({ + left: index === 0 ? 900 : 140, + right: index === 0 ? 1300 : 540, + top: 20, bottom: 220, + width: 400, height: 200 + }) as DOMRect; + }); + + expect(collectPagedDocxTableGeometries(document, { + contentWidthPx: 800, + contentHeightPx: 900 + })).toEqual([{ + ordinal: 1, + widthPercent: 50, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70] + }]); + }); + + it("只覆盖列数一致的分页表格几何并保留连续布局行样式", () => { + const row = { + cells: [{ + columnSpan: 1, + backgroundColor: "#ffffff", + color: "#000000", + bold: true, + italic: false, + alignment: "left" as const + }] + }; + const layout = { + tables: [ + { + ordinal: 1, + widthPercent: 100, + leftOffsetPercent: 0, + columnWidthPercents: [40, 60], + rows: [row] + }, + { + ordinal: 2, + widthPercent: 100, + leftOffsetPercent: 0, + columnWidthPercents: [100], + rows: [row] + } + ], + textBlocks: [], + listItems: [], + inlineCodes: [] + }; + + const merged = mergePagedTableGeometries(layout, [ + { + ordinal: 1, + widthPercent: 90, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70] + }, + { + ordinal: 2, + widthPercent: 80, + leftOffsetPercent: 10, + columnWidthPercents: [20, 80] + } + ]); + + expect(merged.tables[0]).toEqual({ + ...layout.tables[0], + widthPercent: 90, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70] + }); + expect(merged.tables[0]?.rows).toBe(layout.tables[0]?.rows); + expect(merged.tables[1]).toBe(layout.tables[1]); }); it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => { @@ -334,6 +495,382 @@ describe("DOCX 媒体捕获计划", () => { }); }); + it("保留主题表格超出文档内容盒的实测宽度", () => { + document.body.innerHTML = ` +
内容
+ `; + const article = document.querySelector("#write")!; + const table = document.querySelector("table")!; + const cell = document.querySelector("td")!; + article.getBoundingClientRect = () => ({ + left: 100, + right: 900, + top: 0, + bottom: 900, + width: 800, + height: 900, + }) as DOMRect; + table.getBoundingClientRect = () => ({ + left: 100, + right: 980, + top: 20, + bottom: 120, + width: 880, + height: 100, + }) as DOMRect; + cell.getBoundingClientRect = table.getBoundingClientRect; + + const layout = collectDocxDocumentLayoutPlan(document, { + contentWidthPx: 800, + contentHeightPx: 900, + }); + + expect(layout.tables[0]?.widthPercent).toBeCloseTo(110, 8); + expect(layout.tables[0]?.columnWidthPercents).toEqual([100]); + }); + + it("分页前冻结整表实测列宽供拆分页片段复用", () => { + document.body.innerHTML = ` +
AB
+ `; + const table = document.querySelector("table")!; + const cells = Array.from(table.rows[0]!.cells); + table.getBoundingClientRect = () => ({ + left: 100, + right: 500, + top: 0, + bottom: 100, + width: 400, + height: 100 + }) as DOMRect; + cells[0]!.getBoundingClientRect = () => ({ + left: 100, + right: 220, + top: 0, + bottom: 100, + width: 120, + height: 100 + }) as DOMRect; + cells[1]!.getBoundingClientRect = () => ({ + left: 220, + right: 500, + top: 0, + bottom: 100, + width: 280, + height: 100 + }) as DOMRect; + + expect(stabilizePagedTableColumns(document)).toBe(1); + expect(table.dataset.stabilizedColumns).toBe("true"); + expect(table.style.tableLayout).toBe("fixed"); + expect(Array.from(table.querySelectorAll("col")).map( + (column) => column.style.width + )).toEqual(["120px", "280px"]); + expect(Array.from(table.querySelectorAll("col")).map( + (column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx + )).toEqual(["120", "280"]); + expect(Array.from(table.querySelectorAll("th, td")).map( + (cell) => (cell as HTMLElement).style.width + )).toEqual(["120px", "280px"]); + expect(stabilizePagedTableColumns(document)).toBe(0); + }); + + it("DOCX 布局采集保持分页前原始列轨而不受辅助样式二次测量影响", () => { + document.body.innerHTML = ` +
+ + + + + + +
AB
+
+ `; + const article = document.querySelector("#write")!; + const table = document.querySelector("table")!; + const cells = Array.from(table.rows[0]!.cells); + article.getBoundingClientRect = () => ({ + left: 100, right: 500, top: 0, bottom: 100, + width: 400, height: 100 + }) as DOMRect; + table.getBoundingClientRect = article.getBoundingClientRect; + cells[0]!.getBoundingClientRect = () => ({ + left: 100, right: 240, top: 0, bottom: 100, + width: 140, height: 100 + }) as DOMRect; + cells[1]!.getBoundingClientRect = () => ({ + left: 240, right: 500, top: 0, bottom: 100, + width: 260, height: 100 + }) as DOMRect; + + const layout = collectDocxDocumentLayoutPlan(document, { + contentWidthPx: 400, + contentHeightPx: 900 + }); + + expect(layout.tables[0]?.columnWidthPercents).toEqual([30, 70]); + }); + + it("冻结跨列单元格为对应列轨宽度之和", () => { + document.body.innerHTML = ` + + + +
跨列
+ `; + const table = document.querySelector("table")!; + const cells = Array.from(table.querySelectorAll("td")); + table.getBoundingClientRect = () => ({ + left: 100, right: 500, top: 0, bottom: 100, + width: 400, height: 100 + }) as DOMRect; + cells[0]!.getBoundingClientRect = () => ({ + left: 100, right: 500, top: 0, bottom: 50, + width: 400, height: 50 + }) as DOMRect; + cells[1]!.getBoundingClientRect = () => ({ + left: 100, right: 220, top: 50, bottom: 100, + width: 120, height: 50 + }) as DOMRect; + cells[2]!.getBoundingClientRect = () => ({ + left: 220, right: 500, top: 50, bottom: 100, + width: 280, height: 50 + }) as DOMRect; + + expect(stabilizePagedTableColumns(document)).toBe(1); + expect(cells.map((cell) => cell.style.width)).toEqual([ + "400px", "120px", "280px" + ]); + }); + + it("分页片段已有冻结列定义时不重复插入 colgroup", () => { + document.body.innerHTML = ` + + + + +
AB
+ + + +
+ `; + const source = document.querySelector("#source")!; + const rendered = document.querySelector("#rendered")!; + + restorePagedTableStructure(source, rendered); + + expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1); + expect(rendered.querySelectorAll(":scope > colgroup > col")).toHaveLength(2); + expect(rendered.querySelectorAll(":scope > thead")).toHaveLength(1); + expect(Array.from(rendered.children).map((child) => child.tagName)).toEqual([ + "COLGROUP", + "THEAD", + "TBODY" + ]); + }); + + it("分页前仅对可容纳于单页的表格行单元格启用整行保护", () => { + document.body.innerHTML = ` + + + +
普通行内容
超高行内容
+ `; + const table = document.querySelector("table")!; + const normal = document.querySelector("#normal")!; + const tall = document.querySelector("#tall")!; + table.getBoundingClientRect = () => ({ + width: 400, height: 1020, top: 0, right: 400, + bottom: 1020, left: 0 + }) as DOMRect; + normal.getBoundingClientRect = () => ({ + width: 400, height: 120, top: 0, right: 400, + bottom: 120, left: 0 + }) as DOMRect; + tall.getBoundingClientRect = () => ({ + width: 400, height: 900, top: 120, right: 400, + bottom: 1020, left: 0 + }) as DOMRect; + Array.from(table.rows).flatMap((row) => Array.from(row.cells)) + .forEach((cell, index) => { + cell.getBoundingClientRect = () => ({ + width: 200, + height: index < 2 ? 120 : 900, + top: index < 2 ? 0 : 120, + right: index % 2 === 0 ? 200 : 400, + bottom: index < 2 ? 120 : 1020, + left: index % 2 === 0 ? 0 : 200 + }) as DOMRect; + }); + + stabilizePagedTableColumns(document, 800); + + expect(Array.from(normal.cells).map( + (cell) => cell.style.getPropertyValue("break-inside") + )).toEqual(["avoid", "avoid"]); + expect(Array.from(tall.cells).map( + (cell) => cell.style.getPropertyValue("break-inside") + )).toEqual(["auto", "auto"]); + expect(Array.from(normal.cells).every( + (cell) => cell.style.getPropertyPriority("break-inside") === "important" + )).toBe(true); + expect(normal.dataset.mdtpTableRowId).toBe("table-1-row-1"); + expect(normal.dataset.mdtpTableRowKeepWhole).toBe("true"); + expect(tall.dataset.mdtpTableRowId).toBe("table-1-row-2"); + expect(tall.dataset.mdtpTableRowKeepWhole).toBe("false"); + }); + + it("检测分页后缺失的可容纳正文行并施加恢复断点", () => { + document.body.innerHTML = ` +
+ + + +
超高
+
+ +
+ `; + const source = document.querySelector("#source")!; + const rendered = document.querySelector("#rendered")!; + + expect(missingPagedTableRowIds(source, rendered)).toEqual(["row-1"]); + expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1); + const row = source.querySelector( + '[data-mdtp-table-row-id="row-1"]' + )!; + const table = row.closest("table")!; + const marker = table.previousElementSibling as HTMLElement; + expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true"); + expect(marker.textContent).toBe("\u00a0"); + expect(marker.style.getPropertyValue("height")).toBe("1px"); + expect(marker.style.getPropertyValue("margin-bottom")).toBe("-1px"); + expect(marker.style.getPropertyValue("opacity")).toBe("0"); + expect(marker.style.getPropertyValue("break-before")).toBe("page"); + expect(marker.style.getPropertyPriority("break-before")).toBe("important"); + expect(table.dataset.mdtpTableRecovery).toBe("true"); + expect(row.dataset.mdtpTableRowRecovery).toBe("true"); + expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1); + expect(source.querySelectorAll("[data-mdtp-table-recovery-marker]")) + .toHaveLength(1); + + }); + + it("在非首行缺失时拆分续表并将恢复断点放到表格外", () => { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; + const source = document.querySelector("#source")!; + + expect(forcePagedTableRowsToNextPage(source, ["row-2"])).toBe(1); + + const tables = source.querySelectorAll("table"); + expect(tables).toHaveLength(2); + expect(Array.from(tables[0]!.querySelectorAll("tbody > tr")).map( + (row) => (row as HTMLElement).dataset.mdtpTableRowId + )).toEqual(["row-1"]); + expect(Array.from(tables[1]!.querySelectorAll("tbody > tr")).map( + (row) => (row as HTMLElement).dataset.mdtpTableRowId + )).toEqual(["row-2", "row-3"]); + expect(tables[1]!.dataset.mdtpTableRecoveryContinuation).toBe("true"); + expect(tables[1]!.querySelector("colgroup")).not.toBeNull(); + expect(tables[1]!.querySelector("thead")).not.toBeNull(); + expect(tables[0]!.querySelector("tfoot")).toBeNull(); + expect(tables[1]!.querySelector("tfoot")).not.toBeNull(); + const marker = tables[1]!.previousElementSibling as HTMLElement; + expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true"); + expect(marker.style.getPropertyValue("break-before")).toBe("page"); + expect(marker.style.getPropertyPriority("break-before")).toBe("important"); + }); + + it("仅将分页可视区域内真实可见的表格行视为已渲染", () => { + document.body.innerHTML = ` +
+ + +
裁切
可见
+
+
+ + + +
裁切
可见
+
+
+ + +
可见副本
+
+
+ `; + const rect = ( + left: number, + top: number, + right: number, + bottom: number + ) => ({ + left, top, right, bottom, + width: right - left, + height: bottom - top, + x: left, + y: top, + toJSON: () => ({}) + }) as DOMRect; + document.querySelector("#page-1")!.getBoundingClientRect = + () => rect(0, 0, 600, 800); + document.querySelector("#page-2")!.getBoundingClientRect = + () => rect(0, 900, 600, 1700); + document.querySelector("#clipped")!.getBoundingClientRect = + () => rect(0, 810, 600, 850); + document.querySelector("#visible")!.getBoundingClientRect = + () => rect(0, 780, 600, 820); + document.querySelector("#visible-duplicate")! + .getBoundingClientRect = () => rect(0, 920, 600, 960); + + expect(missingPagedTableRowIds( + document.querySelector("#source")!, + document.querySelector("#rendered")! + )).toEqual(["row-clipped"]); + }); + + it("分页片段同步源表冻结列宽而不重复 colgroup", () => { + document.body.innerHTML = ` + + + +
+ + + +
+ `; + const source = document.querySelector("#source")!; + const rendered = document.querySelector("#rendered")!; + + restorePagedTableColumns(source, rendered); + + expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1); + expect(Array.from(rendered.querySelectorAll("col")).map( + (column) => column.style.width + )).toEqual(["120px", "280px"]); + expect(Array.from(rendered.querySelectorAll("col")).map( + (column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx + )).toEqual(["118", "282"]); + expect(rendered.dataset.stabilizedColumns).toBe("true"); + expect(rendered.style.tableLayout).toBe("fixed"); + }); + it("按真实上下文采集行内代码字号、颜色和盒模型", () => { document.body.innerHTML = `
@@ -419,6 +956,42 @@ describe("DOCX 媒体捕获计划", () => { } }); + it("不把普通两端对齐列表的自然末行误判为分散对齐", () => { + document.body.innerHTML = ` +
+
  • 末行保持左对齐
+
+ `; + const article = document.querySelector("#write")!; + article.getBoundingClientRect = () => ({ + left: 0, + right: 800, + top: 0, + bottom: 900, + width: 800, + height: 900 + }) as DOMRect; + const rangePrototype = Object.getPrototypeOf(document.createRange()) as { + getBoundingClientRect?: () => DOMRect; + }; + const original = rangePrototype.getBoundingClientRect; + rangePrototype.getBoundingClientRect = function (this: Range) { + return { + left: this.startOffset * 80, + right: this.startOffset * 80 + 10, + top: 20, + bottom: 32, + width: 10, + height: 12 + } as DOMRect; + }; + try { + expect(collectDocxListItemLayouts(article)[0]?.alignment).toBeUndefined(); + } finally { + rangePrototype.getBoundingClientRect = original; + } + }); + it("采集正文文本块在 Chromium 中的实际行断点", () => { document.body.innerHTML = `
diff --git a/packages/preview-engine/tests/paged-preview.test.ts b/packages/preview-engine/tests/paged-preview.test.ts index 6084712..e98c182 100644 --- a/packages/preview-engine/tests/paged-preview.test.ts +++ b/packages/preview-engine/tests/paged-preview.test.ts @@ -40,6 +40,14 @@ const payload: PagedPreviewPayload = { }; describe("分页预览协议", () => { + it("含行内代码的段落避免被 Paged.js 跨页拆分丢失片段", () => { + const css = buildPagedMediaCss(defaultExportConfig, payload); + + expect(css).toContain("#write > p.md-inline-code-paragraph"); + expect(css).toContain("orphans: 1"); + expect(css).toContain("widows: 1"); + }); + it("生成真实纸张尺寸和页边距 CSS", () => { const css = buildPagedMediaCss(defaultExportConfig, payload); @@ -278,6 +286,12 @@ describe("分页预览协议", () => { expect(documentBaseCss).toContain("white-space: pre-wrap;"); expect(documentBaseCss).toContain("overflow-wrap: anywhere;"); expect(documentBaseCss).toContain("word-break: break-word;"); + expect(documentBaseCss).toMatch( + /#write th,[\s\S]*#write td \{[\s\S]*word-break: break-word;/u, + ); + expect(documentBaseCss).toMatch( + /#write th code,[\s\S]*#write td code \{[\s\S]*white-space: pre-wrap;[\s\S]*overflow-wrap: anywhere;[\s\S]*word-break: break-word;/u, + ); const css = buildPagedMediaCss(defaultExportConfig, payload); expect(css).toContain("#write .md-code-line"); diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index e816872..a359853 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -23,6 +23,7 @@ import hljs from "highlight.js"; import MarkdownIt from "markdown-it"; import type { Options as MarkdownItOptions } from "markdown-it"; import type Renderer from "markdown-it/lib/renderer.mjs"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; import type Token from "markdown-it/lib/token.mjs"; import sanitizeHtml from "sanitize-html"; import { renderDocumentStructure } from "./render-document-structure.js"; @@ -49,6 +50,7 @@ export interface RenderMarkdownOptions { export interface RenderedMarkdown extends Omit { rendererVersion: typeof RENDERER_VERSION; + markdownBody: string; } const markdownOptions: MarkdownItOptions = { @@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions) trust: false }); +const tableBreakMarkupPattern = /^(?:
||
)/iu; + +function tableBreakCandidateRule( + state: StateInline, + silent: boolean +): boolean { + const match = state.src.slice(state.pos).match(tableBreakMarkupPattern); + if (!match) { + return false; + } + if (!silent) { + const token = state.push("table_break_candidate", "", 0); + token.content = match[0]; + } + state.pos += match[0].length; + return true; +} + +markdown.inline.ruler.before( + "html_inline", + "table_break_candidate", + tableBreakCandidateRule +); +markdown.core.ruler.after("inline", "scope_table_breaks", (state) => { + let tableCellDepth = 0; + for (const token of state.tokens) { + if (token.type === "td_open" || token.type === "th_open") { + tableCellDepth += 1; + continue; + } + if (token.type === "td_close" || token.type === "th_close") { + tableCellDepth = Math.max(0, tableCellDepth - 1); + continue; + } + if (token.type !== "inline") { + continue; + } + for (const child of token.children ?? []) { + if (child.type === "table_break_candidate" && tableCellDepth > 0) { + child.type = "table_break"; + } + } + } +}); +markdown.renderer.rules.table_break_candidate = (tokens, index) => + markdown.utils.escapeHtml(tokens[index]?.content ?? ""); +markdown.renderer.rules.table_break = () => "
"; + +const markdownAlertTitles = new Map([ + ["note", "Note"], + ["tip", "Tip"], + ["important", "Important"], + ["warning", "Warning"], + ["caution", "Caution"] +]); + +markdown.core.ruler.after("scope_table_breaks", "github_alerts", (state) => { + for (let index = 0; index < state.tokens.length - 2; index += 1) { + const blockquote = state.tokens[index]; + const paragraph = state.tokens[index + 1]; + const inline = state.tokens[index + 2]; + if ( + blockquote?.type !== "blockquote_open" || + paragraph?.type !== "paragraph_open" || + inline?.type !== "inline" + ) { + continue; + } + const [marker, bodyBreak] = inline.children ?? []; + const match = marker?.type === "text" + ? marker.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/iu) + : undefined; + if (!marker || !match) { + continue; + } + const alertType = match[1]?.toLowerCase(); + const title = alertType ? markdownAlertTitles.get(alertType) : undefined; + if (!alertType || !title) { + continue; + } + blockquote.attrJoin("class", `md-alert md-alert-${alertType}`); + marker.type = "markdown_alert_title"; + marker.content = title; + marker.meta = { alertType }; + if (bodyBreak?.type === "softbreak") { + bodyBreak.type = "markdown_alert_body_break"; + } + } +}); +markdown.renderer.rules.markdown_alert_title = (tokens, index) => { + const token = tokens[index]; + const alertType = typeof token?.meta?.alertType === "string" + ? token.meta.alertType + : "note"; + return `${markdown.utils.escapeHtml(token?.content ?? "")}`; +}; +markdown.renderer.rules.markdown_alert_body_break = () => "

\n

"; + const defaultValidateLink = markdown.validateLink.bind(markdown); markdown.validateLink = (href) => defaultValidateLink(href) || @@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = ( if (standaloneImage) { return '

\n'; } + const inlineToken = tokens[index + 1]; + if ( + inlineToken?.type === "inline" && + (inlineToken.children ?? []).some((child) => child.type === "code_inline") + ) { + tokens[index]?.attrJoin("class", "md-inline-code-paragraph"); + } return defaultParagraphOpenRenderer ? defaultParagraphOpenRenderer( tokens, @@ -367,6 +474,7 @@ export function renderMarkdown( return { rendererVersion: RENDERER_VERSION, + markdownBody: parsed.content, articleHtml, bodyHtml, metadata, diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index dde1698..9b4af5b 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -6,6 +6,38 @@ import { } from "../src/render-markdown.js"; describe("renderMarkdown", () => { + it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => { + const result = renderMarkdown( + "> [!CAUTION]\n> **关键约束**:必须执行。", + ); + + expect(result.bodyHtml).toContain( + '
', + ); + expect(result.bodyHtml).toContain( + '

Caution

\n

关键约束:必须执行。

', + ); + expect(result.bodyHtml).not.toContain("[!CAUTION]"); + }); + + it("为含行内代码的普通段落标记分页保护类", () => { + const result = renderMarkdown("段落前 `inline_code` 段落后"); + + expect(result.articleHtml).toContain( + '

段落前 inline_code 段落后

', + ); + }); + + it("向下游暴露已剥离文首 Front Matter 的正文 Markdown", () => { + const rendered = renderMarkdown( + "---\ntitle: 测试标题\n---\n# 正文\n\n---\n\n后续内容" + ); + + expect(rendered.markdownBody).toBe( + "# 正文\n\n---\n\n后续内容" + ); + expect(rendered.metadata.title).toBe("测试标题"); + }); it("渲染常用 Markdown 扩展并识别能力", () => { const result = renderMarkdown(` # 示例文档 @@ -80,6 +112,39 @@ const answer = 42; expect(result.bodyHtml).toContain('C'); }); + it("只在 GFM 表格单元格内解释无属性 br 换行", () => { + const result = renderMarkdown(` +| 场景 | 内容 | +| --- | --- | +| 标准 | 第一行
第二行
第三行
第四行 | + +正文中的


保持文本。 + +| 转义与代码 | 内容 | +| --- | --- | +| 转义 | \\
与 <br> | +| 代码 | \`
\` | +| 属性 |
| + +\`\`\`html +
+\`\`\` +`); + + expect(result.bodyHtml).toContain( + "第一行
第二行
第三行
第四行" + ); + expect(result.bodyHtml).toContain( + "正文中的 <br>、<br/> 和 <br /> 保持文本" + ); + expect(result.bodyHtml).toContain("<br> 与 <br>"); + expect(result.bodyHtml).toContain("<br>"); + expect(result.bodyHtml).toContain("<br class=\"unsafe\">"); + expect(result.bodyHtml).toContain( + '
'
+    );
+  });
+
   it("读取并规范化 Front Matter 元数据", () => {
     const result = renderMarkdown(`---
 title: 项目报告
diff --git a/scripts/build-font-packs.mjs b/scripts/build-font-packs.mjs
index 93fed62..35c11cb 100644
--- a/scripts/build-font-packs.mjs
+++ b/scripts/build-font-packs.mjs
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
 const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
 const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
 const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
-const appVersion = process.env.npm_package_version || "0.6.1";
+const appVersion = process.env.npm_package_version || "0.6.2";
 
 if (
   bundle.schemaVersion !== 1 ||
diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs
index a691aea..0fcbf9e 100644
--- a/scripts/docx-layout-visual-matrix-cases.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.mjs
@@ -71,6 +71,29 @@ function hasIndependentCover(manifest) {
   return profiles.includes("project-report") || profiles.includes("tender");
 }
 
+export function resolveEffectiveCoverPageCount({
+  caseDefinition,
+  scope,
+  sourceMetadata
+}) {
+  assert(caseDefinition && Number.isInteger(caseDefinition.coverPageCount),
+    "矩阵场景缺少有效封面页数");
+  if (scope !== "corpus") {
+    return caseDefinition.coverPageCount;
+  }
+  const profile = sourceMetadata?.document?.profile;
+  return profile === "project-report" || profile === "tender" ? 1 : 0;
+}
+
+export function isCorpusTextColorEquivalent(metrics) {
+  return Boolean(
+    metrics &&
+    metrics.inkIou >= 0.98 &&
+    metrics.edgeIou >= 0.98 &&
+    metrics.meanAbsoluteError <= 8
+  );
+}
+
 export function readBundledThemeMatrixDefinitions(themesDirectory) {
   assert(
     typeof themesDirectory === "string" && themesDirectory.length > 0,
diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs
index b23644d..1152e33 100644
--- a/scripts/docx-layout-visual-matrix-cases.test.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.test.mjs
@@ -8,7 +8,9 @@ import {
   DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS,
   DOCX_LAYOUT_MATRIX_ORIENTATIONS,
   getDocxFontGateFailures,
+  isCorpusTextColorEquivalent,
   readBundledThemeMatrixDefinitions,
+  resolveEffectiveCoverPageCount,
   summarizeDocxLayoutVisualMatrix
 } from "./docx-layout-visual-matrix-cases.mjs";
 
@@ -156,3 +158,40 @@ test("四套独立封面主题扩展为 40 个严格封面场景", () => {
     ]
   );
 });
+
+test("真实语料按文档语义而不是主题能力判定独立封面", () => {
+  const caseDefinition = { coverPageCount: 1 };
+  assert.equal(resolveEffectiveCoverPageCount({
+    caseDefinition,
+    scope: "full",
+    sourceMetadata: {}
+  }), 1);
+  assert.equal(resolveEffectiveCoverPageCount({
+    caseDefinition,
+    scope: "corpus",
+    sourceMetadata: {}
+  }), 0);
+  assert.equal(resolveEffectiveCoverPageCount({
+    caseDefinition,
+    scope: "corpus",
+    sourceMetadata: { document: { profile: "tender" } }
+  }), 1);
+});
+
+test("短文本仅在墨迹边缘等价且像素误差低时忽略抗锯齿色差", () => {
+  assert.equal(isCorpusTextColorEquivalent({
+    inkIou: 1,
+    edgeIou: 1,
+    meanAbsoluteError: 6.9
+  }), true);
+  assert.equal(isCorpusTextColorEquivalent({
+    inkIou: 1,
+    edgeIou: 0.999,
+    meanAbsoluteError: 23
+  }), false);
+  assert.equal(isCorpusTextColorEquivalent({
+    inkIou: 0.8,
+    edgeIou: 1,
+    meanAbsoluteError: 4
+  }), false);
+});
diff --git a/scripts/docx-real-world-corpus.mjs b/scripts/docx-real-world-corpus.mjs
new file mode 100644
index 0000000..5787adb
--- /dev/null
+++ b/scripts/docx-real-world-corpus.mjs
@@ -0,0 +1,100 @@
+import fs from "node:fs";
+import path from "node:path";
+
+export const DOCX_REAL_WORLD_CORPUS = Object.freeze([
+  {
+    id: "changqing-tender-requirements",
+    label: "长庆油田智能健康技术服务招标技术要求",
+    markdownPath: "tmp/长庆油田智能健康技术服务招标技术要求.md",
+    requiredFeatures: ["inline-code", "table-math-alignment"]
+  },
+  {
+    id: "health-data-schema",
+    label: "运动健康类和营养饮食数据结构升级",
+    markdownPath: "tmp/运动健康类和营养饮食数据数据结构升级.md",
+    requiredFeatures: ["json-code-indentation"],
+    codeIndentationProbe: {
+      rootLine: "{",
+      nestedLineIncludes: '"heart_rate_daily"',
+      minimumIndentPt: 4
+    }
+  },
+  {
+    id: "m4n-table-design",
+    label: "M4N 全量表设计模块化讲解",
+    markdownPath: "tmp/数据中台项目周报_2026_W30_附件_M4N全量表设计模块化讲解.md",
+    requiredFeatures: ["mermaid", "long-document"]
+  }
+]);
+
+export function resolveDocxRealWorldCorpus(repositoryDirectory) {
+  return DOCX_REAL_WORLD_CORPUS.map((entry) => ({
+    ...entry,
+    absoluteMarkdownPath: path.resolve(
+      repositoryDirectory,
+      entry.markdownPath
+    ),
+    byteLength: fs.existsSync(path.resolve(repositoryDirectory, entry.markdownPath))
+      ? fs.statSync(path.resolve(repositoryDirectory, entry.markdownPath)).size
+      : 0
+  }));
+}
+
+export function sortDocxRealWorldCorpusBySize(repositoryDirectory) {
+  return resolveDocxRealWorldCorpus(repositoryDirectory).sort(
+    (first, second) =>
+      first.byteLength - second.byteLength ||
+      first.id.localeCompare(second.id, "en")
+  );
+}
+
+export function validateDocxRealWorldCorpus(repositoryDirectory) {
+  const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
+  const failures = [];
+  for (const entry of corpus) {
+    if (!fs.existsSync(entry.absoluteMarkdownPath)) {
+      failures.push(`${entry.id} 缺少 Markdown:${entry.markdownPath}`);
+      continue;
+    }
+    const source = fs.readFileSync(entry.absoluteMarkdownPath, "utf8");
+    if (!source.trim()) {
+      failures.push(`${entry.id} Markdown 为空`);
+    }
+    if (
+      entry.requiredFeatures.includes("json-code-indentation") &&
+      !/```json\s*[\s\S]*?\n[\t ]+\S/gu.test(source)
+    ) {
+      failures.push(`${entry.id} 缺少带缩进的 JSON 围栏探针`);
+    }
+    if (
+      entry.requiredFeatures.includes("inline-code") &&
+      !/(^|[^`])`[^`\n]+`([^`]|$)/mu.test(source)
+    ) {
+      failures.push(`${entry.id} 缺少行内代码探针`);
+    }
+    if (
+      entry.requiredFeatures.includes("table-math-alignment") &&
+      !/^\|.*\$[^$]+\$.*\|/mu.test(source)
+    ) {
+      failures.push(`${entry.id} 缺少表格公式对齐探针`);
+    }
+  }
+  return { corpus, failures };
+}
+
+export function summarizeDocxReleaseGateMatrix({
+  baselineCaseCount,
+  corpusCaseCounts
+}) {
+  const corpusCaseCount = Object.values(corpusCaseCounts).reduce(
+    (sum, value) => sum + value,
+    0
+  );
+  return {
+    baselineCaseCount,
+    corpusDocumentCount: Object.keys(corpusCaseCounts).length,
+    corpusCaseCounts,
+    corpusCaseCount,
+    totalCaseCount: baselineCaseCount + corpusCaseCount
+  };
+}
diff --git a/scripts/docx-real-world-corpus.test.mjs b/scripts/docx-real-world-corpus.test.mjs
new file mode 100644
index 0000000..86bc68e
--- /dev/null
+++ b/scripts/docx-real-world-corpus.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import path from "node:path";
+import test from "node:test";
+
+import {
+  DOCX_REAL_WORLD_CORPUS,
+  sortDocxRealWorldCorpusBySize,
+  summarizeDocxReleaseGateMatrix,
+  validateDocxRealWorldCorpus
+} from "./docx-real-world-corpus.mjs";
+
+const repositoryDirectory = path.resolve(import.meta.dirname, "..");
+
+test("冻结三份真实 Markdown 语料及必需特征", () => {
+  const validation = validateDocxRealWorldCorpus(repositoryDirectory);
+  assert.deepEqual(validation.failures, []);
+  assert.equal(validation.corpus.length, 3);
+  assert.equal(new Set(DOCX_REAL_WORLD_CORPUS.map((entry) => entry.id)).size, 3);
+  assert.ok(
+    DOCX_REAL_WORLD_CORPUS.some((entry) =>
+      entry.requiredFeatures.includes("json-code-indentation")
+    )
+  );
+  assert.ok(
+    DOCX_REAL_WORLD_CORPUS.some((entry) =>
+      entry.requiredFeatures.includes("table-math-alignment")
+    )
+  );
+});
+
+test("真实 Markdown 按文件字节数稳定升序执行", () => {
+  const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
+  assert.deepEqual(
+    corpus.map((entry) => entry.byteLength),
+    corpus.map((entry) => entry.byteLength).toSorted((a, b) => a - b)
+  );
+  assert.ok(corpus.every((entry) => entry.byteLength > 0));
+});
+
+test("发布门禁矩阵聚合为 140+420=560", () => {
+  assert.deepEqual(
+    summarizeDocxReleaseGateMatrix({
+      baselineCaseCount: 140,
+      corpusCaseCounts: Object.fromEntries(
+        DOCX_REAL_WORLD_CORPUS.map((entry) => [entry.id, 140])
+      )
+    }),
+    {
+      baselineCaseCount: 140,
+      corpusDocumentCount: 3,
+      corpusCaseCounts: {
+        "changqing-tender-requirements": 140,
+        "health-data-schema": 140,
+        "m4n-table-design": 140
+      },
+      corpusCaseCount: 420,
+      totalCaseCount: 560
+    }
+  );
+});
diff --git a/scripts/docx-release-gate-suites.mjs b/scripts/docx-release-gate-suites.mjs
new file mode 100644
index 0000000..c1cfdc9
--- /dev/null
+++ b/scripts/docx-release-gate-suites.mjs
@@ -0,0 +1,232 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+
+import {
+  createDocxLayoutVisualMatrixCases,
+  readBundledThemeMatrixDefinitions
+} from "./docx-layout-visual-matrix-cases.mjs";
+import { validateDocxRealWorldCorpus } from "./docx-real-world-corpus.mjs";
+
+export const DOCX_RELEASE_GATE_CASE_COUNT = 140;
+export const DOCX_RELEASE_GATE_SCHEMA_VERSION = 2;
+
+function assert(condition, message) {
+  if (!condition) {
+    throw new Error(message);
+  }
+}
+
+export function createDocxReleaseGateSuites(repositoryDirectory) {
+  const validation = validateDocxRealWorldCorpus(repositoryDirectory);
+  assert(
+    validation.failures.length === 0,
+    `真实语料无效:${validation.failures.join(";")}`
+  );
+  const themes = readBundledThemeMatrixDefinitions(
+    path.join(repositoryDirectory, "themes")
+  );
+  const cases = createDocxLayoutVisualMatrixCases(themes);
+  assert(
+    cases.length === DOCX_RELEASE_GATE_CASE_COUNT,
+    `单套矩阵应为 ${DOCX_RELEASE_GATE_CASE_COUNT},实际 ${cases.length}`
+  );
+  const caseIds = cases.map((entry) => entry.id);
+  return [
+    {
+      id: "synthetic-baseline-140",
+      label: "标准合成基线 140",
+      kind: "synthetic",
+      order: 0,
+      outputDirectoryName: "synthetic-baseline-140",
+      expectedCaseCount: caseIds.length,
+      caseIds
+    },
+    ...validation.corpus.map((entry, index) => ({
+      id: entry.id,
+      label: `${entry.label}(${entry.byteLength} bytes)`,
+      kind: "corpus",
+      order: index + 1,
+      outputDirectoryName:
+        `corpus-${String(index + 1).padStart(2, "0")}-${entry.byteLength}-${entry.id}`,
+      expectedCaseCount: caseIds.length,
+      caseIds,
+      markdownPath: entry.markdownPath,
+      absoluteMarkdownPath: entry.absoluteMarkdownPath,
+      markdownByteLength: entry.byteLength,
+      requiredFeatures: entry.requiredFeatures
+    }))
+  ];
+}
+
+export function isSuiteAggregatePassed(aggregate, suite) {
+  return Boolean(
+    aggregate &&
+    aggregate.suite?.id === suite.id &&
+    aggregate.execution?.expectedCaseCount === suite.expectedCaseCount &&
+    aggregate.execution?.completedCaseCount === suite.expectedCaseCount &&
+    aggregate.execution?.passedCaseCount === suite.expectedCaseCount &&
+    aggregate.gatePassed === true
+  );
+}
+
+export function resolveDocxReleaseGateSuite({
+  suites,
+  requestedId,
+  aggregates,
+  allowOutOfOrder = false
+}) {
+  assert(Array.isArray(suites) && suites.length === 4, "发布门禁必须包含四套独立矩阵");
+  const target = !requestedId || requestedId === "next"
+    ? suites.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite))
+    : suites.find((suite) => suite.id === requestedId);
+  if (!target) {
+    if (!requestedId || requestedId === "next") {
+      return undefined;
+    }
+    throw new Error(`未知发布门禁套件:${requestedId}`);
+  }
+  if (!allowOutOfOrder || !requestedId || requestedId === "next") {
+    const unmetPrerequisite = suites
+      .slice(0, target.order)
+      .find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite));
+    assert(
+      !unmetPrerequisite,
+      `必须先通过前置套件 ${unmetPrerequisite?.id}`
+    );
+  }
+  return target;
+}
+
+function collectFingerprintFiles(repositoryDirectory, suite) {
+  const roots = [
+    "themes",
+    "apps/server/scripts",
+    "apps/server/src",
+    "packages/application/src",
+    "packages/core/src",
+    "packages/document-visual-diff/src",
+    "packages/docx-engine/assets",
+    "packages/docx-engine/src",
+    "packages/preview-engine/src",
+    "packages/renderer/src"
+  ];
+  const files = [
+    "package-lock.json",
+    "scripts/docx-layout-visual-matrix-cases.mjs",
+    "scripts/docx-real-world-corpus.mjs",
+    "scripts/docx-release-gate-suites.mjs",
+    "scripts/verify-docx-layout-visual-matrix.mjs",
+    "scripts/verify-docx-release-gate-suite.mjs"
+  ];
+  const allowedExtension = /\.(?:css|js|json|lua|mjs|ts)$/iu;
+  const visit = (relativeDirectory) => {
+    const absoluteDirectory = path.join(repositoryDirectory, relativeDirectory);
+    if (!fs.existsSync(absoluteDirectory)) {
+      return;
+    }
+    for (const entry of fs.readdirSync(absoluteDirectory, { withFileTypes: true })) {
+      const relativePath = path.join(relativeDirectory, entry.name);
+      if (entry.isDirectory()) {
+        visit(relativePath);
+      } else if (allowedExtension.test(entry.name)) {
+        files.push(relativePath);
+      }
+    }
+  };
+  for (const root of roots) {
+    visit(root);
+  }
+  if (suite.markdownPath) {
+    files.push(suite.markdownPath);
+  }
+  return [...new Set(files)].sort((left, right) => left.localeCompare(right, "en"));
+}
+
+export function createDocxReleaseGateFingerprint(repositoryDirectory, suite) {
+  const hash = crypto.createHash("sha256");
+  hash.update(`schema:${DOCX_RELEASE_GATE_SCHEMA_VERSION}\n`);
+  hash.update(`suite:${suite.id}\n`);
+  for (const relativePath of collectFingerprintFiles(repositoryDirectory, suite)) {
+    const absolutePath = path.join(repositoryDirectory, relativePath);
+    if (!fs.existsSync(absolutePath)) {
+      hash.update(`${relativePath}:missing\n`);
+      continue;
+    }
+    hash.update(`${relativePath}\0`);
+    hash.update(fs.readFileSync(absolutePath));
+    hash.update("\0");
+  }
+  return hash.digest("hex");
+}
+
+export function readJsonIfExists(filePath) {
+  if (!fs.existsSync(filePath)) {
+    return undefined;
+  }
+  try {
+    return JSON.parse(fs.readFileSync(filePath, "utf8"));
+  } catch {
+    return undefined;
+  }
+}
+
+const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
+
+function sleepSync(milliseconds) {
+  Atomics.wait(
+    new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)),
+    0,
+    0,
+    milliseconds
+  );
+}
+
+export function writeJsonAtomic(filePath, value, options = {}) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
+  fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
+  const rename = options.rename ?? fs.renameSync;
+  const sleep = options.sleep ?? sleepSync;
+  const retryDeadline = Date.now() + (options.retryWindowMs ?? 3_000);
+  let attempt = 0;
+  while (true) {
+    try {
+      rename(temporaryPath, filePath);
+      return;
+    } catch (error) {
+      if (
+        !WINDOWS_RENAME_RETRY_CODES.has(error?.code) ||
+        Date.now() >= retryDeadline
+      ) {
+        fs.rmSync(temporaryPath, { force: true });
+        throw error;
+      }
+      attempt += 1;
+      sleep(Math.min(100, 10 * attempt));
+    }
+  }
+}
+
+export function summarizeSuiteProgress({ suite, fingerprint, records }) {
+  const currentRecords = suite.caseIds
+    .map((caseId) => records[caseId])
+    .filter((record) => record?.fingerprint === fingerprint);
+  const passedCaseCount = currentRecords.filter((record) => record.status === "passed").length;
+  const gateFailedCaseCount = currentRecords.filter((record) => record.status === "gate-failed").length;
+  const errorCaseCount = currentRecords.filter((record) => record.status === "error").length;
+  const completedCaseCount = currentRecords.length;
+  return {
+    expectedCaseCount: suite.expectedCaseCount,
+    completedCaseCount,
+    pendingCaseCount: suite.expectedCaseCount - completedCaseCount,
+    passedCaseCount,
+    gateFailedCaseCount,
+    errorCaseCount,
+    complete: completedCaseCount === suite.expectedCaseCount,
+    gatePassed:
+      passedCaseCount === suite.expectedCaseCount &&
+      gateFailedCaseCount === 0 &&
+      errorCaseCount === 0
+  };
+}
diff --git a/scripts/docx-release-gate-suites.test.mjs b/scripts/docx-release-gate-suites.test.mjs
new file mode 100644
index 0000000..715373b
--- /dev/null
+++ b/scripts/docx-release-gate-suites.test.mjs
@@ -0,0 +1,135 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+  createDocxReleaseGateSuites,
+  isSuiteAggregatePassed,
+  resolveDocxReleaseGateSuite,
+  summarizeSuiteProgress,
+  writeJsonAtomic
+} from "./docx-release-gate-suites.mjs";
+
+const repositoryDirectory = path.resolve(
+  path.dirname(fileURLToPath(import.meta.url)),
+  ".."
+);
+
+function passedAggregate(suite) {
+  return {
+    suite: { id: suite.id },
+    execution: {
+      expectedCaseCount: 140,
+      completedCaseCount: 140,
+      passedCaseCount: 140
+    },
+    gatePassed: true
+  };
+}
+
+test("发布门禁拆分为四套独立 140 且真实文档按字节升序", () => {
+  const suites = createDocxReleaseGateSuites(repositoryDirectory);
+  assert.equal(suites.length, 4);
+  assert.equal(suites[0].id, "synthetic-baseline-140");
+  assert.ok(suites.every((suite) => suite.expectedCaseCount === 140));
+  assert.deepEqual(
+    suites.slice(1).map((suite) => suite.markdownByteLength),
+    suites.slice(1).map((suite) => suite.markdownByteLength).toSorted((a, b) => a - b)
+  );
+  assert.ok(suites.slice(1).every((suite) => suite.outputDirectoryName.includes(String(suite.markdownByteLength))));
+});
+
+test("next 严格遵守合成基线和真实文档大小顺序", () => {
+  const suites = createDocxReleaseGateSuites(repositoryDirectory);
+  const aggregates = new Map();
+  assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[0].id);
+  aggregates.set(suites[0].id, passedAggregate(suites[0]));
+  assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[1].id);
+  assert.throws(
+    () => resolveDocxReleaseGateSuite({ suites, requestedId: suites[2].id, aggregates }),
+    /必须先通过前置套件/u
+  );
+  for (const suite of suites) {
+    aggregates.set(suite.id, passedAggregate(suite));
+  }
+  assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }), undefined);
+});
+
+test("只有显式指定套件时才允许受控越过前置门禁", () => {
+  const suites = createDocxReleaseGateSuites(repositoryDirectory);
+  const aggregates = new Map();
+  assert.equal(
+    resolveDocxReleaseGateSuite({
+      suites,
+      requestedId: suites[3].id,
+      aggregates,
+      allowOutOfOrder: true
+    }).id,
+    suites[3].id
+  );
+  assert.equal(
+    resolveDocxReleaseGateSuite({
+      suites,
+      requestedId: "next",
+      aggregates,
+      allowOutOfOrder: true
+    }).id,
+    suites[0].id
+  );
+});
+
+test("套件进度独立统计通过、门禁失败和基础设施错误", () => {
+  const suite = createDocxReleaseGateSuites(repositoryDirectory)[0];
+  const records = {
+    [suite.caseIds[0]]: { fingerprint: "same", status: "passed" },
+    [suite.caseIds[1]]: { fingerprint: "same", status: "gate-failed" },
+    [suite.caseIds[2]]: { fingerprint: "same", status: "error" },
+    [suite.caseIds[3]]: { fingerprint: "old", status: "passed" }
+  };
+  const progress = summarizeSuiteProgress({ suite, fingerprint: "same", records });
+  assert.deepEqual(progress, {
+    expectedCaseCount: 140,
+    completedCaseCount: 3,
+    pendingCaseCount: 137,
+    passedCaseCount: 1,
+    gateFailedCaseCount: 1,
+    errorCaseCount: 1,
+    complete: false,
+    gatePassed: false
+  });
+  assert.equal(isSuiteAggregatePassed({ suite: { id: suite.id }, execution: progress, gatePassed: false }, suite), false);
+});
+
+test("Windows 瞬时文件锁不会中断原子进度写入", () => {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mdtp-gate-atomic-"));
+  const filePath = path.join(directory, "progress.json");
+  let attempts = 0;
+  try {
+    writeJsonAtomic(filePath, { completed: 9 }, {
+      rename(source, destination) {
+        attempts += 1;
+        if (attempts < 3) {
+          const error = new Error("temporary Windows lock");
+          error.code = "EPERM";
+          throw error;
+        }
+        fs.renameSync(source, destination);
+      },
+      sleep() {}
+    });
+    assert.equal(attempts, 3);
+    assert.deepEqual(
+      JSON.parse(fs.readFileSync(filePath, "utf8")),
+      { completed: 9 }
+    );
+    assert.deepEqual(
+      fs.readdirSync(directory).filter((name) => name.endsWith(".tmp")),
+      []
+    );
+  } finally {
+    fs.rmSync(directory, { recursive: true, force: true });
+  }
+});
diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs
index bb8b380..9bbcbab 100644
--- a/scripts/verify-docx-layout-visual-matrix.mjs
+++ b/scripts/verify-docx-layout-visual-matrix.mjs
@@ -4,6 +4,7 @@ import path from "node:path";
 import { promisify } from "node:util";
 import { DOMParser } from "@xmldom/xmldom";
 import { unzipSync } from "fflate";
+import matter from "gray-matter";
 
 import {
   createPdfDocumentSnapshot,
@@ -11,6 +12,8 @@ import {
   createWordPdfAdapter,
   createWpsPdfAdapter,
   getBlockingVisualDiffIssues,
+  isCrossEngineRasterEquivalent,
+  normalizePdfEditableText,
   renderPdfVisualDiffHtml,
   serializePdfVisualDiffJson
 } from "../packages/document-visual-diff/dist/index.js";
@@ -21,9 +24,14 @@ import {
 import {
   createDocxLayoutVisualMatrixCases,
   getDocxFontGateFailures,
+  isCorpusTextColorEquivalent,
   readBundledThemeMatrixDefinitions,
+  resolveEffectiveCoverPageCount,
   summarizeDocxLayoutVisualMatrix
 } from "./docx-layout-visual-matrix-cases.mjs";
+import {
+  DOCX_REAL_WORLD_CORPUS
+} from "./docx-real-world-corpus.mjs";
 
 const execFileAsync = promisify(execFile);
 const WORD_NAMESPACE =
@@ -32,10 +40,10 @@ const MATH_NAMESPACE =
   "http://schemas.openxmlformats.org/officeDocument/2006/math";
 const decoder = new TextDecoder();
 const repositoryDirectory = path.resolve(import.meta.dirname, "..");
-const outputDirectory = path.join(
+const outputDirectory = path.resolve(
   repositoryDirectory,
-  "output",
-  "docx-layout-visual-matrix"
+  process.env.MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR?.trim() ||
+    path.join("output", "docx-layout-visual-matrix")
 );
 const matrixScript = path.join(
   repositoryDirectory,
@@ -64,6 +72,23 @@ const selectedOrientation =
   process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
 const selectedMarginScenarioId =
   process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
+const expectInlineCodeProbes =
+  process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
+const selectedCorpusId =
+  process.env.MD_TO_PDF_CORPUS_ID?.trim() || undefined;
+const corpusDefinition = selectedCorpusId
+  ? DOCX_REAL_WORLD_CORPUS.find((entry) => entry.id === selectedCorpusId)
+  : undefined;
+const corpusSourceMetadata = selectedScope === "corpus" &&
+  process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()
+  ? matter(fs.readFileSync(
+      path.resolve(
+        repositoryDirectory,
+        process.env.MD_TO_PDF_R4_MARKDOWN_PATH.trim()
+      ),
+      "utf8"
+    )).data
+  : undefined;
 const scopedCases = selectedCaseId
   ? cases.filter((entry) => entry.id === selectedCaseId)
   : selectedScope === "cover"
@@ -185,6 +210,43 @@ function paragraphBlockKind(paragraph, styleId) {
   return "paragraph";
 }
 
+function directWordChildren(node, localName) {
+  return Array.from(node?.childNodes ?? []).filter(
+    (child) =>
+      child.nodeType === 1 &&
+      child.localName === localName &&
+      child.namespaceURI === WORD_NAMESPACE
+  );
+}
+
+function nearestWordAncestor(node, localName) {
+  let current = node?.parentNode;
+  while (current) {
+    if (
+      current.localName === localName &&
+      current.namespaceURI === WORD_NAMESPACE
+    ) {
+      return current;
+    }
+    current = current.parentNode;
+  }
+  return undefined;
+}
+
+function paragraphTableCoordinates(paragraph, tableIndexes) {
+  const cell = nearestWordAncestor(paragraph, "tc");
+  const row = nearestWordAncestor(paragraph, "tr");
+  const table = nearestWordAncestor(paragraph, "tbl");
+  if (!cell || !row || !table) {
+    return undefined;
+  }
+  return {
+    tableGroupId: `table-${tableIndexes.get(table)}`,
+    tableRowIndex: directWordChildren(table, "tr").indexOf(row),
+    tableColumnIndex: directWordChildren(row, "tc").indexOf(cell)
+  };
+}
+
 function extractEditableContract(docxPath) {
   const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
   const documentXml = entries["word/document.xml"];
@@ -193,13 +255,83 @@ function extractEditableContract(docxPath) {
     decoder.decode(documentXml),
     "application/xml"
   );
+  const tableIndexes = new Map(
+    Array.from(document.getElementsByTagName("*")).filter(
+      (node) => node.localName === "tbl" && node.namespaceURI === WORD_NAMESPACE
+    ).map((table, index) => [table, index])
+  );
   let section = "cover";
   let hasCoverSection = false;
   const paragraphs = [];
+  let inlineCodeParagraphCount = 0;
+  let inlineCodeExactLineRuleCount = 0;
+  let pureMathTableParagraphCount = 0;
+  let stabilizedPureMathTableParagraphCount = 0;
   for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
     (node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
   )) {
     const descendants = Array.from(paragraph.getElementsByTagName("*"));
+    const inlineCode = descendants.some(
+      (node) =>
+        node.localName === "rStyle" &&
+        node.namespaceURI === WORD_NAMESPACE &&
+        (node.getAttribute("w:val") || node.getAttribute("val")) ===
+          "VerbatimChar"
+    );
+    if (inlineCode) {
+      inlineCodeParagraphCount += 1;
+      if (
+        descendants.some(
+          (node) =>
+            node.localName === "spacing" &&
+            node.namespaceURI === WORD_NAMESPACE &&
+            (node.getAttribute("w:lineRule") ||
+              node.getAttribute("lineRule")) === "exact"
+        )
+      ) {
+        inlineCodeExactLineRuleCount += 1;
+      }
+    }
+    if (hasAncestor(paragraph, "tc")) {
+      const directContent = Array.from(paragraph.childNodes).filter(
+        (node) =>
+          node.nodeType === 1 &&
+          !(
+            node.localName === "pPr" &&
+            node.namespaceURI === WORD_NAMESPACE
+          )
+      );
+      const directMath = directContent.filter(
+        (node) =>
+          node.localName === "oMath" &&
+          node.namespaceURI === MATH_NAMESPACE
+      );
+      const stabilizers = directContent.filter(
+        (node) =>
+          node.localName === "r" &&
+          node.namespaceURI === WORD_NAMESPACE &&
+          Array.from(node.getElementsByTagName("*")).some(
+            (descendant) =>
+              descendant.localName === "noProof" &&
+              descendant.namespaceURI === WORD_NAMESPACE
+          ) &&
+          Array.from(node.getElementsByTagName("*")).some(
+            (descendant) =>
+              descendant.localName === "t" &&
+              descendant.namespaceURI === WORD_NAMESPACE &&
+              descendant.textContent === "\u200B"
+          )
+      );
+      if (
+        directMath.length > 0 &&
+        directContent.length === directMath.length + stabilizers.length
+      ) {
+        pureMathTableParagraphCount += 1;
+        if (stabilizers.length > 0) {
+          stabilizedPureMathTableParagraphCount += 1;
+        }
+      }
+    }
     const hasSectionBreak = descendants.some(
       (node) =>
         node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
@@ -209,20 +341,53 @@ function extractEditableContract(docxPath) {
       section = "body";
       continue;
     }
-    const text = descendants.flatMap((node) =>
-      node.localName === "t" &&
-      (node.namespaceURI === WORD_NAMESPACE ||
-        node.namespaceURI === MATH_NAMESPACE)
-        ? [node.textContent ?? ""]
-        : []
-    ).join("");
+    const hardBreakSegments = [""];
+    const mathCharacterIndexes = [];
+    let normalizedCharacterOffset = 0;
+    for (const node of descendants) {
+      if (
+        node.localName === "t" &&
+        (node.namespaceURI === WORD_NAMESPACE ||
+          node.namespaceURI === MATH_NAMESPACE)
+      ) {
+        const nodeText = (node.textContent ?? "").replace(
+          /[\u200B\u2060\uFEFF]/gu,
+          ""
+        );
+        hardBreakSegments[hardBreakSegments.length - 1] += nodeText;
+        const normalizedNodeLength = Array.from(
+          normalizePdfEditableText(nodeText)
+        ).length;
+        if (node.namespaceURI === MATH_NAMESPACE) {
+          for (let index = 0; index < normalizedNodeLength; index += 1) {
+            mathCharacterIndexes.push(normalizedCharacterOffset + index);
+          }
+        }
+        normalizedCharacterOffset += normalizedNodeLength;
+      } else if (
+        node.localName === "br" &&
+        node.namespaceURI === WORD_NAMESPACE
+      ) {
+        hardBreakSegments.push("");
+      }
+    }
+    const text = hardBreakSegments.join("");
     if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
       continue;
     }
     const styleId = paragraphStyleId(paragraph);
+    const tableCoordinates = paragraphTableCoordinates(paragraph, tableIndexes);
     paragraphs.push({
       index: paragraphs.length,
       text,
+      ...(mathCharacterIndexes.length > 0
+        ? { mathCharacterIndexes }
+        : {}),
+      ...(hardBreakSegments.length > 1
+        ? { hardBreakSegments }
+        : {}),
+      ...(inlineCode ? { hasInlineCode: true } : {}),
+      ...(tableCoordinates ?? {}),
       ...(styleId ? { styleId } : {}),
       role: paragraphRole(styleId),
       blockKind: paragraphBlockKind(paragraph, styleId),
@@ -236,7 +401,13 @@ function extractEditableContract(docxPath) {
   }
   return {
     text: paragraphs.map((paragraph) => paragraph.text).join(""),
-    paragraphs
+    paragraphs,
+    translationInvariants: {
+      inlineCodeParagraphCount,
+      inlineCodeExactLineRuleCount,
+      pureMathTableParagraphCount,
+      stabilizedPureMathTableParagraphCount
+    }
   };
 }
 
@@ -338,6 +509,82 @@ function inspectInlineCodeContinuity(snapshot, label) {
   };
 }
 
+function inspectCodeIndentation(snapshot, label, probe) {
+  if (!probe) {
+    return {
+      label,
+      applicable: false,
+      passed: true,
+      failures: []
+    };
+  }
+  for (const page of snapshot.pages) {
+    const nestedIndex = page.lines.findIndex((line) =>
+      line.text.includes(probe.nestedLineIncludes)
+    );
+    if (nestedIndex < 0) {
+      continue;
+    }
+    const nested = page.lines[nestedIndex];
+    const root = page.lines
+      .slice(0, nestedIndex)
+      .reverse()
+      .find(
+        (line) =>
+          line.text.trim() === probe.rootLine &&
+          nested.baselineY - line.baselineY <= 30
+      );
+    if (!root) {
+      continue;
+    }
+    const indentPt = nested.bounds.x - root.bounds.x;
+    const passed = indentPt >= probe.minimumIndentPt;
+    return {
+      label,
+      applicable: true,
+      passed,
+      pageNumber: page.pageNumber,
+      rootText: root.text,
+      nestedText: nested.text,
+      rootXPt: root.bounds.x,
+      nestedXPt: nested.bounds.x,
+      indentPt,
+      failures: passed
+        ? []
+        : [
+            `${label}: CODE_INDENTATION_MISMATCH - JSON 嵌套行缩进 ${indentPt.toFixed(2)}pt,小于 ${probe.minimumIndentPt}pt`
+          ]
+    };
+  }
+  return {
+    label,
+    applicable: true,
+    passed: false,
+    failures: [
+      `${label}: CODE_INDENTATION_PROBE_UNAVAILABLE - 未定位 JSON 根行与嵌套行`
+    ]
+  };
+}
+
+function docxTranslationInvariantFailures(invariants) {
+  const failures = [];
+  if (invariants.inlineCodeExactLineRuleCount > 0) {
+    failures.push(
+      `DOCX: INLINE_CODE_EXACT_LINE_BOX - ${invariants.inlineCodeExactLineRuleCount} 个行内代码段落仍使用 exact 行盒`
+    );
+  }
+  if (
+    invariants.pureMathTableParagraphCount !==
+    invariants.stabilizedPureMathTableParagraphCount
+  ) {
+    failures.push(
+      "DOCX: TABLE_MATH_ALIGNMENT_UNSTABLE - " +
+        `${invariants.pureMathTableParagraphCount - invariants.stabilizedPureMathTableParagraphCount} 个纯公式表格段落缺少对齐稳定结构`
+    );
+  }
+  return failures;
+}
+
 function buildPageSemantics(pageCount, coverPageCount, config) {
   assert(
     pageCount > coverPageCount,
@@ -423,6 +670,108 @@ function reportCoverGateFailures(report, label) {
     .map((issue) => `${label}: ${issue.code} - ${issue.message}`);
 }
 
+function metricRatio(first, second) {
+  const maximum = Math.max(first, second);
+  return maximum > 0 ? Math.min(first, second) / maximum : 1;
+}
+
+const MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA = 55;
+
+function reportCorpusGateFailures(
+  report,
+  label,
+  coverPageCount
+) {
+  const failures = [];
+  if ((report.basic.candidateEditableSimilarity ?? 0) < 0.995) {
+    failures.push(
+      `${label}: EDITABLE_CONTENT_SIMILARITY - Office 可编辑正文相似度 ` +
+        `${((report.basic.candidateEditableSimilarity ?? 0) * 100).toFixed(3)}% 低于 99.5%`
+    );
+  }
+  const strictKinds = new Set([
+    "heading",
+    "title",
+    "caption",
+    "table-header",
+    "code-block"
+  ]);
+  for (const comparison of report.basic.semanticBlockVisuals ?? []) {
+    const index = comparison.expectation.index + 1;
+    const visualUnavailable =
+      comparison.status === "unavailable" ||
+      comparison.lines.length === 0 ||
+      comparison.issues?.some(
+        (issue) => issue.code === "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
+      );
+    if (visualUnavailable) {
+      failures.push(
+        `${label}: SEMANTIC_BLOCK_VISUAL_UNAVAILABLE - 第 ${index} 个元素块无法建立视觉观测`
+      );
+      continue;
+    }
+    for (const line of comparison.lines) {
+      const metrics = line.metrics;
+      if (!metrics) {
+        continue;
+      }
+      const heightRatio = metricRatio(
+        metrics.baselineHeightPx,
+        metrics.candidateHeightPx
+      );
+      if (heightRatio < 0.78) {
+        failures.push(
+          `${label}: ELEMENT_LINE_BOX_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行高度比例 ${heightRatio.toFixed(3)} 低于 0.78`
+        );
+      }
+      const widthRatio = metricRatio(
+        metrics.baselineWidthPx,
+        metrics.candidateWidthPx
+      );
+      if (comparison.lines.length === 1 && widthRatio < 0.85) {
+        failures.push(
+          `${label}: ELEMENT_WIDTH_MISMATCH - 第 ${index} 个单行元素块宽度比例 ${widthRatio.toFixed(3)} 低于 0.85`
+        );
+      }
+      const minimumIou = strictKinds.has(
+        comparison.expectation.blockKind
+      ) ? 0.45 : 0.3;
+      if (
+        comparison.lines.length === 1 &&
+        metrics.inkIou < minimumIou &&
+        metrics.edgeIou < minimumIou
+      ) {
+        failures.push(
+          `${label}: ELEMENT_RASTER_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行墨迹与边缘 IoU 均低于 ${minimumIou}`
+        );
+      }
+      const textReflow = line.issues?.some(
+        (issue) => issue.details?.textReflow === true
+      );
+      const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
+        metrics,
+        textReflow,
+        comparison.expectation.blockKind,
+        comparison.expectation.hasInlineCode === true
+      );
+      if (!textReflow &&
+        !crossEngineRasterEquivalent &&
+        !isCorpusTextColorEquivalent(metrics) && (
+        metrics.backgroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA ||
+        metrics.foregroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA
+      )) {
+        failures.push(
+          `${label}: ELEMENT_COLOR_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行颜色差异超限`
+        );
+      }
+    }
+  }
+  if (coverPageCount > 0) {
+    failures.push(...reportCoverGateFailures(report, label));
+  }
+  return [...new Set(failures)];
+}
+
 function reportSummary(report) {
   const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
     (paragraph) => paragraph.issues
@@ -599,9 +948,18 @@ assert(
   `视觉矩阵用例不存在:${selectedCaseId}`
 );
 assert(
-  selectedScope === "full" || selectedScope === "cover",
+  ["full", "cover", "corpus"].includes(selectedScope),
   `不支持的视觉矩阵作用域:${selectedScope}`
 );
+assert(
+  selectedScope !== "corpus" || corpusDefinition,
+  `真实语料门禁缺少有效 MD_TO_PDF_CORPUS_ID:${selectedCorpusId}`
+);
+assert(
+  selectedScope !== "corpus" ||
+    Boolean(process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()),
+  "真实语料门禁必须显式提供 MD_TO_PDF_R4_MARKDOWN_PATH"
+);
 assert(
   !selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
   `不支持的视觉矩阵方向:${selectedOrientation}`
@@ -632,6 +990,11 @@ for (const caseDefinition of selectedCases) {
   );
   const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
   const editable = extractEditableContract(docxPath);
+  const effectiveCoverPageCount = resolveEffectiveCoverPageCount({
+    caseDefinition,
+    scope: selectedScope,
+    sourceMetadata: corpusSourceMetadata
+  });
   const wordGeneration = await wordAdapter.generate({ docxPath });
   const wpsGeneration = await wpsAdapter.generate({ docxPath });
   const wordPdfPath = path.join(caseDirectory, "word.pdf");
@@ -668,17 +1031,17 @@ for (const caseDefinition of selectedCases) {
   });
   const chromiumPageSemantics = buildPageSemantics(
     chromium.pageCount,
-    caseDefinition.coverPageCount,
+    effectiveCoverPageCount,
     result.exportConfig
   );
   const wordPageSemantics = buildPageSemantics(
     word.pageCount,
-    caseDefinition.coverPageCount,
+    effectiveCoverPageCount,
     result.exportConfig
   );
   const wpsPageSemantics = buildPageSemantics(
     wps.pageCount,
-    caseDefinition.coverPageCount,
+    effectiveCoverPageCount,
     result.exportConfig
   );
   const reportOptions = {
@@ -715,6 +1078,23 @@ for (const caseDefinition of selectedCases) {
     word: inspectInlineCodeContinuity(word, "Microsoft Word"),
     wps: inspectInlineCodeContinuity(wps, "WPS Writer")
   };
+  const codeIndentation = {
+    chromium: inspectCodeIndentation(
+      chromium,
+      "Chromium",
+      corpusDefinition?.codeIndentationProbe
+    ),
+    word: inspectCodeIndentation(
+      word,
+      "Microsoft Word",
+      corpusDefinition?.codeIndentationProbe
+    ),
+    wps: inspectCodeIndentation(
+      wps,
+      "WPS Writer",
+      corpusDefinition?.codeIndentationProbe
+    )
+  };
 
   const rasterDirectory = path.join(caseDirectory, "raster-pages");
   fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -742,9 +1122,13 @@ for (const caseDefinition of selectedCases) {
       renderedFonts: wps.fonts,
       engine: "wps"
     }),
-    ...inlineCodeContinuity.chromium.failures,
-    ...inlineCodeContinuity.word.failures,
-    ...inlineCodeContinuity.wps.failures,
+    ...(expectInlineCodeProbes
+      ? [
+          ...inlineCodeContinuity.chromium.failures,
+          ...inlineCodeContinuity.word.failures,
+          ...inlineCodeContinuity.wps.failures
+        ]
+      : []),
     ...reportGateFailures(wordReport, "Chromium/Word"),
     ...reportGateFailures(wpsReport, "Chromium/WPS"),
     ...reportGateFailures(officeReport, "Word/WPS")
@@ -755,7 +1139,46 @@ for (const caseDefinition of selectedCases) {
         ...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
         ...reportCoverGateFailures(officeReport, "Word/WPS")
       ])]
-    : allCurrentFailures;
+    : selectedScope === "corpus"
+      ? [...new Set([
+          ...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
+          ...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
+          ...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
+          ...getDocxFontGateFailures({
+            theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
+            inspection: result.docx.inspection,
+            renderedFonts: word.fonts,
+            engine: "word"
+          }),
+          ...getDocxFontGateFailures({
+            theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
+            inspection: result.docx.inspection,
+            renderedFonts: wps.fonts,
+            engine: "wps"
+          }),
+          ...docxTranslationInvariantFailures(
+            editable.translationInvariants
+          ),
+          ...codeIndentation.chromium.failures,
+          ...codeIndentation.word.failures,
+          ...codeIndentation.wps.failures,
+          ...reportCorpusGateFailures(
+            wordReport,
+            "Chromium/Word",
+            effectiveCoverPageCount
+          ),
+          ...reportCorpusGateFailures(
+            wpsReport,
+            "Chromium/WPS",
+            effectiveCoverPageCount
+          ),
+          ...reportCorpusGateFailures(
+            officeReport,
+            "Word/WPS",
+            effectiveCoverPageCount
+          )
+        ])]
+      : allCurrentFailures;
   gateFailures.push(
     ...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
   );
@@ -772,7 +1195,7 @@ for (const caseDefinition of selectedCases) {
     themeDefaultMargins: caseDefinition.themeDefaultMargins,
     paper: caseDefinition.paper,
     exportConfig: result.exportConfig,
-    coverPageCount: caseDefinition.coverPageCount,
+    coverPageCount: effectiveCoverPageCount,
     pages: {
       chromium: chromium.pageCount,
       word: word.pageCount,
@@ -787,7 +1210,9 @@ for (const caseDefinition of selectedCases) {
       wps: wps.fonts
     },
     editableParagraphCount: editable.paragraphs.length,
+    translationInvariants: editable.translationInvariants,
     inlineCodeContinuity,
+    codeIndentation,
     reports: {
       word: reportSummary(wordReport),
       wps: reportSummary(wpsReport),
@@ -811,6 +1236,14 @@ const summary = {
   matrixDefinition,
   selectedCaseId,
   selectedScope,
+  corpus: corpusDefinition
+    ? {
+        id: corpusDefinition.id,
+        label: corpusDefinition.label,
+        markdownPath: corpusDefinition.markdownPath,
+        requiredFeatures: corpusDefinition.requiredFeatures
+      }
+    : undefined,
   selectedOrientation,
   selectedMarginScenarioId,
   execution: {
diff --git a/scripts/verify-docx-release-gate-560.mjs b/scripts/verify-docx-release-gate-560.mjs
new file mode 100644
index 0000000..bb0dad7
--- /dev/null
+++ b/scripts/verify-docx-release-gate-560.mjs
@@ -0,0 +1,4 @@
+process.stderr.write(
+  "verify-docx-release-gate-560 已改为兼容入口:每次只执行下一套独立 140,不再启动整批 560。\n"
+);
+await import("./verify-docx-release-gate-suite.mjs");
diff --git a/scripts/verify-docx-release-gate-suite.mjs b/scripts/verify-docx-release-gate-suite.mjs
new file mode 100644
index 0000000..0f9f9e6
--- /dev/null
+++ b/scripts/verify-docx-release-gate-suite.mjs
@@ -0,0 +1,277 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { promisify } from "node:util";
+
+import {
+  createDocxReleaseGateFingerprint,
+  createDocxReleaseGateSuites,
+  DOCX_RELEASE_GATE_SCHEMA_VERSION,
+  readJsonIfExists,
+  resolveDocxReleaseGateSuite,
+  summarizeSuiteProgress,
+  writeJsonAtomic
+} from "./docx-release-gate-suites.mjs";
+
+const execFileAsync = promisify(execFile);
+const repositoryDirectory = path.resolve(import.meta.dirname, "..");
+const matrixScript = path.join(repositoryDirectory, "scripts", "verify-docx-layout-visual-matrix.mjs");
+const outputRoot = path.resolve(
+  repositoryDirectory,
+  process.env.MD_TO_PDF_RELEASE_GATE_OUTPUT_DIR?.trim() ||
+    "output/docx-release-gate-v0.6.2"
+);
+const requestedSuiteId = process.env.MD_TO_PDF_RELEASE_GATE_SUITE?.trim() || "next";
+const selectedCaseId = process.env.MD_TO_PDF_RELEASE_GATE_CASE?.trim() || undefined;
+const allowOutOfOrder =
+  process.env.MD_TO_PDF_RELEASE_GATE_ALLOW_OUT_OF_ORDER?.trim() === "1";
+const requestedCaseLimitText =
+  process.env.MD_TO_PDF_RELEASE_GATE_CASE_LIMIT?.trim() || undefined;
+const requestedCaseLimit = requestedCaseLimitText
+  ? Number.parseInt(requestedCaseLimitText, 10)
+  : undefined;
+if (
+  requestedCaseLimitText &&
+  (!Number.isInteger(requestedCaseLimit) || requestedCaseLimit <= 0)
+) {
+  throw new Error("MD_TO_PDF_RELEASE_GATE_CASE_LIMIT 必须是正整数");
+}
+
+function escapeHtml(value) {
+  return String(value)
+    .replaceAll("&", "&")
+    .replaceAll("<", "<")
+    .replaceAll(">", ">")
+    .replaceAll('"', """);
+}
+
+function readAggregates(suites) {
+  return new Map(suites.map((suite) => {
+    const aggregate = readJsonIfExists(
+      path.join(outputRoot, suite.outputDirectoryName, "aggregate.json")
+    );
+    const currentFingerprint = createDocxReleaseGateFingerprint(
+      repositoryDirectory,
+      suite
+    );
+    return [
+      suite.id,
+      aggregate?.fingerprint === currentFingerprint ? aggregate : undefined
+    ];
+  }));
+}
+
+function renderSuiteHtml(aggregate) {
+  const rows = aggregate.cases.map((entry) =>
+    `${escapeHtml(entry.caseId)}${escapeHtml(entry.status)}${escapeHtml(entry.finishedAt || "")}${entry.summaryPath ? `JSON` : "-"}${escapeHtml(entry.error || entry.gateFailures?.join(";") || "")}`
+  ).join("");
+  return `${escapeHtml(aggregate.suite.label)}

${escapeHtml(aggregate.suite.label)}

${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。

${rows}
场景状态完成时间报告问题
`; +} + +function buildAggregate({ suite, fingerprint, records }) { + const execution = summarizeSuiteProgress({ suite, fingerprint, records }); + return { + schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + fingerprint, + suite: { + id: suite.id, + label: suite.label, + kind: suite.kind, + order: suite.order, + markdownPath: suite.markdownPath, + markdownByteLength: suite.markdownByteLength + }, + execution, + gatePassed: execution.gatePassed, + cases: suite.caseIds.map((caseId) => records[caseId]) + .filter((record) => record?.fingerprint === fingerprint) + }; +} + +function writeSuiteReports({ suiteDirectory, suite, fingerprint, records }) { + const aggregate = buildAggregate({ suite, fingerprint, records }); + writeJsonAtomic(path.join(suiteDirectory, "progress.json"), { + schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION, + updatedAt: aggregate.generatedAt, + fingerprint, + records + }); + writeJsonAtomic(path.join(suiteDirectory, "aggregate.json"), aggregate); + fs.writeFileSync(path.join(suiteDirectory, "aggregate.html"), renderSuiteHtml(aggregate), "utf8"); + return aggregate; +} + +function writeTopLevelIndex(suites) { + const entries = suites.map((suite) => ({ + suite: { + id: suite.id, + label: suite.label, + kind: suite.kind, + order: suite.order, + outputDirectoryName: suite.outputDirectoryName, + markdownByteLength: suite.markdownByteLength + }, + aggregate: readJsonIfExists(path.join(outputRoot, suite.outputDirectoryName, "aggregate.json")) + })); + const index = { + schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + strategy: "four-independent-140-suites", + totalExpectedCaseCount: 560, + entries + }; + writeJsonAtomic(path.join(outputRoot, "index.json"), index); + const rows = entries.map(({ suite, aggregate }) => + `${suite.order + 1}${escapeHtml(suite.label)}${aggregate?.execution?.completedCaseCount ?? 0}/140${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"}报告` + ).join(""); + fs.writeFileSync(path.join(outputRoot, "index.html"), `DOCX 4×140 发布门禁

DOCX 4×140 独立发布门禁

先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。

${rows}
顺序套件进度状态独立报告
`, "utf8"); +} + +function parseChildSummary(stdout) { + const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : String(stdout || ""); + return JSON.parse(text.trim()); +} + +fs.mkdirSync(outputRoot, { recursive: true }); +const suites = createDocxReleaseGateSuites(repositoryDirectory); +const suite = resolveDocxReleaseGateSuite({ + suites, + requestedId: requestedSuiteId, + aggregates: readAggregates(suites), + allowOutOfOrder +}); +if (!suite) { + writeTopLevelIndex(suites); + process.stdout.write("四套独立 140 门禁均已通过,无需重复执行。\n"); + process.exit(0); +} +if (selectedCaseId && !suite.caseIds.includes(selectedCaseId)) { + throw new Error(`套件 ${suite.id} 不包含场景 ${selectedCaseId}`); +} + +const suiteDirectory = path.join(outputRoot, suite.outputDirectoryName); +const logsDirectory = path.join(suiteDirectory, "logs"); +fs.mkdirSync(logsDirectory, { recursive: true }); +fs.writeFileSync( + path.join(suiteDirectory, "night-run.pid"), + `${process.pid}\n`, + "utf8" +); +const fingerprint = createDocxReleaseGateFingerprint(repositoryDirectory, suite); +const previousProgress = readJsonIfExists(path.join(suiteDirectory, "progress.json")); +const records = previousProgress?.records && typeof previousProgress.records === "object" + ? previousProgress.records + : {}; +const runStartedAt = new Date().toISOString(); +const runPath = path.join(suiteDirectory, "run.json"); +writeJsonAtomic(runPath, { + schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION, + pid: process.pid, + processStartedAt: runStartedAt, + command: process.argv, + suiteId: suite.id, + suiteFingerprint: fingerprint, + allowOutOfOrder, + ...(requestedCaseLimit ? { requestedCaseLimit } : {}), + status: "running" +}); + +const requestedCaseIds = selectedCaseId + ? [selectedCaseId] + : requestedCaseLimit + ? suite.caseIds.slice(0, requestedCaseLimit) + : suite.caseIds; +for (const caseId of requestedCaseIds) { + if (records[caseId]?.fingerprint === fingerprint && records[caseId]?.status === "passed") { + process.stderr.write(`[DOCX gate ${suite.id}] ${caseId} 已通过,跳过\n`); + continue; + } + process.stderr.write(`[DOCX gate ${suite.id}] 执行 ${caseId}\n`); + const environment = { + ...process.env, + MD_TO_PDF_LAYOUT_VISUAL_CASE: caseId, + MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR: suiteDirectory, + MD_TO_PDF_LAYOUT_VISUAL_SCOPE: suite.kind === "synthetic" ? "full" : "corpus", + MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES: suite.kind === "synthetic" ? "1" : "0" + }; + if (suite.kind === "corpus") { + environment.MD_TO_PDF_CORPUS_ID = suite.id; + environment.MD_TO_PDF_R4_MARKDOWN_PATH = suite.markdownPath; + } else { + delete environment.MD_TO_PDF_CORPUS_ID; + delete environment.MD_TO_PDF_R4_MARKDOWN_PATH; + } + const startedAt = new Date().toISOString(); + let record; + try { + const { stdout, stderr } = await execFileAsync(process.execPath, [matrixScript], { + cwd: repositoryDirectory, + windowsHide: true, + timeout: 24 * 60 * 60 * 1000, + maxBuffer: 256 * 1024 * 1024, + env: environment + }); + fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8"); + fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr, "utf8"); + const summary = parseChildSummary(stdout); + record = { + caseId, + fingerprint, + status: summary.gatePassed ? "passed" : "gate-failed", + startedAt, + finishedAt: new Date().toISOString(), + gateFailures: summary.gateFailures ?? [], + summaryPath: `summary-${caseId}.json`, + matrixPath: `matrix-${caseId}.html` + }; + } catch (error) { + const stdout = Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : String(error?.stdout || ""); + const stderr = Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : String(error?.stderr || ""); + fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8"); + fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr || String(error?.stack || error), "utf8"); + try { + const summary = parseChildSummary(stdout); + record = { + caseId, + fingerprint, + status: "gate-failed", + startedAt, + finishedAt: new Date().toISOString(), + gateFailures: summary.gateFailures ?? [String(error?.message || error)], + summaryPath: `summary-${caseId}.json`, + matrixPath: `matrix-${caseId}.html` + }; + } catch { + record = { + caseId, + fingerprint, + status: "error", + startedAt, + finishedAt: new Date().toISOString(), + error: String(error?.message || error) + }; + } + } + records[caseId] = record; + writeSuiteReports({ suiteDirectory, suite, fingerprint, records }); + writeTopLevelIndex(suites); +} + +const aggregate = writeSuiteReports({ suiteDirectory, suite, fingerprint, records }); +writeTopLevelIndex(suites); +writeJsonAtomic(runPath, { + schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION, + pid: process.pid, + processStartedAt: runStartedAt, + completedAt: new Date().toISOString(), + command: process.argv, + suiteId: suite.id, + suiteFingerprint: fingerprint, + status: aggregate.gatePassed ? "passed" : "completed-with-failures", + execution: aggregate.execution +}); +process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`); +if (!selectedCaseId && !aggregate.gatePassed) { + process.exitCode = 1; +}