release: 发布 v0.6.2 DOCX 真实文档修复

新增能力:将 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 均已生成并校验。
This commit is contained in:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
+2 -2
View File
@@ -57,8 +57,8 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop
``` ```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.6.1/`。该目录被 Git 忽略。公司内部分发以 `apps/desktop/out/v0.6.2/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 `MorphDoc-0.6.2-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。 提示。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.6.1", "version": "0.6.2",
"private": true, "private": true,
"productName": "MorphDoc", "productName": "MorphDoc",
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具", "description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
+1 -1
View File
@@ -160,7 +160,7 @@ describe("桌面发行构建链", () => {
artifactPrefix: "MorphDoc" artifactPrefix: "MorphDoc"
}); });
expect(desktopManifest).toMatchObject({ expect(desktopManifest).toMatchObject({
version: "0.6.1", version: "0.6.2",
productName: "MorphDoc" productName: "MorphDoc"
}); });
expect(builderConfig).toContain("appId: brand.appId"); expect(builderConfig).toContain("appId: brand.appId");
+299 -10
View File
@@ -1,10 +1,11 @@
import { createHash } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core"; import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
import matter from "gray-matter";
import { buildApp } from "../dist/app.js"; import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js"; import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js"; import { createPdfGenerator } from "../dist/pdf-engine.js";
@@ -35,6 +36,27 @@ const fontPackRoot = path.resolve(
); );
const decoder = new TextDecoder(); 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 = [ const inlineCodeContinuityProbes = [
{ {
id: "paragraph", id: "paragraph",
@@ -61,11 +83,99 @@ const inlineCodeContinuityMarkdown = `
- 列表前\`mdtp_ic_l\`列表后 - 列表前\`mdtp_ic_l\`列表后
| 门禁 | 内容 | | 门禁 | 内容 | 邻接单元格 |
| --- | --- | --- |
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
| 场景 | 内容 |
| --- | --- | | --- | --- |
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | | 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
| 叁式 | 连甲<br><br>连乙 |
| 肆式 | <br>边界<br> |
| 伍式 | 转甲\\<br>转乙 &lt;br&gt; 转丙 |
| 陆式 | 属甲<br class="unsafe">属乙 |
| 柒式 | 码甲\`<br>\`码乙 |
表格外保留 外一<br>外二 原文。
\`\`\`html
围一<br>围二
\`\`\`
`; `;
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: "转甲&lt;br&gt;转乙 &lt;br&gt; 转丙",
hardBreakCount: 0
},
{
id: "attribute",
marker: "属甲",
expectedText: "属甲&lt;br class=\"unsafe\"&gt;属乙",
hardBreakCount: 0
},
{
id: "inline-code",
marker: "码甲",
expectedText: "码甲&lt;br&gt;码乙",
hardBreakCount: 0,
requiredCharacterStyle: "VerbatimChar"
},
{
id: "outside-table",
marker: "外一",
expectedText: "表格外保留 外一&lt;br&gt;外二 原文。",
hardBreakCount: 0
},
{
id: "fenced-code",
marker: "围一",
expectedText: "围一&lt;br&gt;围二",
hardBreakCount: 0,
requiredParagraphStyle: "SourceCode"
}
];
const contentTypes = new Map([ const contentTypes = new Map([
[".css", "text/css; charset=utf-8"], [".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"], [".html", "text/html; charset=utf-8"],
@@ -289,6 +399,59 @@ function inspectDocx(content) {
codeRunPattern.test(paragraph) codeRunPattern.test(paragraph)
}; };
}); });
const breakProbes = docxBreakParagraphProbes.map((probe) => {
const matchingParagraphs = paragraphs.map((paragraph) => ({
paragraph,
actualText: [
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\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(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
const characterStylePresent = !probe.requiredCharacterStyle ||
runs.some((run) =>
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
run.includes("&lt;br&gt;")
);
const paragraphStylePresent = !probe.requiredParagraphStyle ||
new RegExp(
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
"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 { return {
bytes: content.byteLength, bytes: content.byteLength,
sha256: sha256(content), sha256: sha256(content),
@@ -314,6 +477,7 @@ function inspectDocx(content) {
inlineCodeContinuityPassed: inlineCodeContinuity.every( inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed (probe) => probe.passed
), ),
tableBreakSemantics,
requiredStylesPresent: [ requiredStylesPresent: [
"Normal", "Normal",
"Heading1", "Heading1",
@@ -342,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) {
return { response, content }; 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(/<t[dh](?:\s[^>]*)?>[\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(
"标甲<br />标乙<br />标丙<br />标丁"
)
},
{
id: "case-insensitive",
passed: cellFor("大甲").includes(
"大甲<br />大乙<br />大丙<br />大丁"
)
},
{
id: "multiple",
passed: cellFor("连甲").includes(
"连甲<br /><br />连乙"
)
},
{
id: "boundary",
passed: cellFor("边界").includes("<br />边界<br />")
},
{
id: "escaped-and-entity",
passed: cellFor("转甲").includes(
"转甲&lt;br&gt;转乙 &lt;br&gt; 转丙"
)
},
{
id: "attribute-literal",
passed: cellFor("属甲").includes(
"属甲&lt;br class=\"unsafe\"&gt;属乙"
)
},
{
id: "inline-code-literal",
passed: cellFor("码甲").includes(
"码甲<code>&lt;br&gt;</code>码乙"
)
},
{
id: "outside-table-literal",
passed: fragmentFor("外一", "外二").includes(
"外一&lt;br&gt;外二"
)
},
{
id: "fenced-code-literal",
passed: (() => {
const fragment = fragmentFor("围一", "围二");
return fragment.includes("&lt;") && fragment.includes("br") &&
fragment.includes("&gt;") && !/<br(?:\s|\/?>)/iu.test(fragment);
})()
}
];
const adjacentA = cellFor("邻甲");
const adjacentB = cellFor("旁甲");
const adjacentCellsIsolated =
adjacentA.includes("邻甲<br />邻乙") &&
adjacentB.includes("旁甲<br />旁乙") &&
!adjacentA.includes("旁甲") &&
!adjacentB.includes("邻甲");
return {
probes,
adjacentCellsIsolated,
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
};
}
function responseDiagnostics(response, kind) { function responseDiagnostics(response, kind) {
return { return {
serverTiming: response.headers.get("server-timing"), serverTiming: response.headers.get("server-timing"),
@@ -410,6 +671,10 @@ assert(
); );
const selectedThemeId = const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined; 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 exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId) ? allThemes.filter((theme) => theme.id === selectedThemeId)
@@ -468,9 +733,13 @@ try {
for (const theme of themes) { for (const theme of themes) {
capturedDocxDocumentLayout = undefined; capturedDocxDocumentLayout = undefined;
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`); 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}`); 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({ const exportConfig = mergeExportConfig({
...defaultExportConfig, ...defaultExportConfig,
name: `${theme.name} R4 生产链验收`, name: `${theme.name} R4 生产链验收`,
@@ -495,11 +764,21 @@ try {
}, exportConfigOverride); }, exportConfigOverride);
const payload = { const payload = {
markdown, markdown,
fileName: `${theme.id}.md`, fileName: path.basename(samplePath),
language: "zh-CN", language: "zh-CN",
resources: [], resources: [],
exportConfig 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( const pdf = await requestArtifact(
origin, origin,
"/api/pdf", "/api/pdf",
@@ -526,10 +805,16 @@ try {
inspection.requiredStylesPresent, inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式` `主题 ${theme.id} 缺少标准 Word 样式`
); );
if (appendInlineCodeProbes) {
assert( assert(
inspection.inlineCodeContinuityPassed, inspection.inlineCodeContinuityPassed,
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}` `主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
); );
assert(
inspection.tableBreakSemantics.passed,
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
);
}
const standard = standardByTheme.get(theme.id); const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`); assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert( assert(
@@ -545,8 +830,8 @@ try {
); );
} }
const expectsOfficialDualEndedRow = Boolean( const expectsOfficialDualEndedRow = Boolean(
standard.metadata?.document?.profile === "official" && sourceMetadata.document?.profile === "official" &&
standard.metadata.document.signatory sourceMetadata.document.signatory
); );
if (expectsOfficialDualEndedRow) { if (expectsOfficialDualEndedRow) {
assert( assert(
@@ -566,9 +851,12 @@ try {
} }
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`); 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(pdfPath, pdf.content);
fs.writeFileSync(docxPath, docx.content); const docxPath = writeArtifactWithBusyFallback(
preferredDocxPath,
docx.content
);
results.push({ results.push({
id: theme.id, id: theme.id,
name: theme.name, name: theme.name,
@@ -576,6 +864,7 @@ try {
compatibleProfiles: theme.compatibleProfiles, compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath), sample: path.relative(repositoryDirectory, samplePath),
exportConfig, exportConfig,
htmlBreakSemantics,
pdf: { pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath), outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength, bytes: pdf.content.byteLength,
+27 -2
View File
@@ -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({ return reply.code(500).send({
error: "PDF_GENERATION_FAILED", error: "PDF_GENERATION_FAILED",
message: "PDF 生成失败" message: "PDF 生成失败"
@@ -559,7 +568,23 @@ export function buildApp(options: BuildAppOptions = {}) {
if (error instanceof DocxExportServiceError) { if (error instanceof DocxExportServiceError) {
if (error.statusCode >= 500) { if (error.statusCode >= 500) {
request.log.error( 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" "DOCX generation service failed"
); );
} }
+1 -1
View File
@@ -10,7 +10,7 @@ const app = buildApp({
? { ? {
fontPacks: { fontPacks: {
roots: [fontPackRoot], roots: [fontPackRoot],
appVersion: process.env.APP_VERSION ?? "0.6.1" appVersion: process.env.APP_VERSION ?? "0.6.2"
} }
} }
: {}) : {})
+1 -1
View File
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
timeoutMs: readIntegerEnvironment( timeoutMs: readIntegerEnvironment(
environment, environment,
"PDF_TIMEOUT_MS", "PDF_TIMEOUT_MS",
60_000, 180_000,
1_000 1_000
) )
}; };
+2 -2
View File
@@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) {
return readFileSync(`${projectRoot}/${relativePath}`, "utf8"); return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
} }
describe("Docker v0.6.1 部署契约", () => { describe("Docker v0.6.2 部署契约", () => {
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => { it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
const dockerfile = readProjectFile("deploy/Dockerfile"); const dockerfile = readProjectFile("deploy/Dockerfile");
const workspaces = [ const workspaces = [
@@ -82,7 +82,7 @@ describe("Docker v0.6.1 部署契约", () => {
); );
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs"); expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
expect(compose).toContain( 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.split(/\r?\n/u)).toContain(".local");
expect(dockerignore).toContain("!output/font-packs/root/**"); expect(dockerignore).toContain("!output/font-packs/root/**");
+1 -1
View File
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
expect(readPdfEngineRuntimeLimits({})).toEqual({ expect(readPdfEngineRuntimeLimits({})).toEqual({
concurrency: 2, concurrency: 2,
maxQueue: 8, maxQueue: 8,
timeoutMs: 60_000 timeoutMs: 180_000
}); });
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => { describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => { it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.6.1"); expect(APP_VERSION).toBe("0.6.2");
expect(APP_VERSION_LABEL).toBe("v0.6.1"); expect(APP_VERSION_LABEL).toBe("v0.6.2");
}); });
it("允许浏览器加载同源 Web Manifest", () => { it("允许浏览器加载同源 Web Manifest", () => {
+6 -6
View File
@@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建: 可以直接指定版本化镜像名称构建:
```powershell ```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 docker compose -f deploy\compose.yaml build
``` ```
@@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell ```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_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build 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 不挂载宿主机字体目录,因此部署端 `/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。 无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
@@ -104,7 +104,7 @@ docker compose -f deploy\compose.yaml up -d
发布前执行真实内置字体门禁: 发布前执行真实内置字体门禁:
```powershell ```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 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_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 | | `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 | | `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_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 | | `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 | | `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
+1 -1
View File
@@ -19,7 +19,7 @@ services:
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}" PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}" PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}" 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 FONT_PACK_ROOT: /app/.local/font-packs
volumes: volumes:
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro" - "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
+1 -1
View File
@@ -175,7 +175,7 @@ const environment = {
...buildEnvironment, ...buildEnvironment,
MD_TO_PDF_PORT: String(port), MD_TO_PDF_PORT: String(port),
MD_TO_PDF_THEME_DIR: themeRoot, MD_TO_PDF_THEME_DIR: themeRoot,
MD_TO_PDF_APP_VERSION: "0.6.1" MD_TO_PDF_APP_VERSION: "0.6.2"
}; };
const compose = [ const compose = [
"compose", "compose",
+40 -2
View File
@@ -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. 当前概况 ## 1. 当前概况
@@ -1534,10 +1552,30 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1` 文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1`
标签统一指向最终发行状态。 标签统一指向最终发行状态。
### 阶段三:v0.6.2 DOCX 真实文档门禁修复
- 发布门禁已由一次性 560 拆分为四套独立 140:标准合成基线优先,随后
`tmp` 三份真实 Markdown 的文件字节数升序逐套执行。每套独立保存
`progress.json``aggregate.json` 和 HTML 报告;旧 560 后台流程停用;
- 标准合成基线已加入表格内 `<br>``<br/>``<br />` 与行内代码
`` `<br>` `` 的隔离语义检查,并覆盖全 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 可视化编辑工具; - Front Matter 可视化编辑工具;
- ECharts YAML 可视化配置工具; - ECharts YAML 可视化配置工具;
+34
View File
@@ -0,0 +1,34 @@
# MorphDoc · 墨呈 v0.6.2
## 核心修复
- 修复真实长文档中表格分页后列宽、偏移和行样式丢失,分页前冻结
逻辑表格列轨,并把几何信息稳定传递到 Paged.js 分片。
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的
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.2Serif、Sans、Mono
字体继续直接内置,无需安装系统字体。
- Docker 镜像为 `md-to-pdf:v0.6.2`,内置固定 Chromium、Pandoc 3.9.0.2
和受校验字体包。
- DOCX 继续保持原生可编辑段落、Run、列表和表格结构,未将正文替换为
整页图片。
- Windows 安装器仍未进行 Authenticode 签名,适用于公司内部发布。
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.6.1", "version": "0.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.6.1", "version": "0.6.2",
"license": "UNLICENSED", "license": "UNLICENSED",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -22,7 +22,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.6.1", "version": "0.6.2",
"devDependencies": { "devDependencies": {
"@md-to-pdf/application": "0.4.1", "@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0",
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.6.1", "version": "0.6.2",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"description": "面向结构化 Markdown 的主题化文档创作与发布工具", "description": "面向结构化 Markdown 的主题化文档创作与发布工具",
@@ -33,6 +33,8 @@
"release:stage": "node scripts/stage-release.mjs", "release:stage": "node scripts/stage-release.mjs",
"test:release-stage": "node --test scripts/stage-release.test.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-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", "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-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", "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-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-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", "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" "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": { "engines": {
@@ -1,11 +1,11 @@
import { import {
MarkdownDocumentParseError, MarkdownDocumentParseError,
renderMarkdown renderMarkdown,
type RenderedMarkdown
} from "@md-to-pdf/renderer"; } from "@md-to-pdf/renderer";
import { import {
docxExportRequestSchema, docxExportRequestSchema,
type DocxExportRequest, type DocxExportRequest,
type RenderedMarkdownDocument,
type ThemeManifest type ThemeManifest
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import type { DocxFontSource } from "@md-to-pdf/docx-engine"; import type { DocxFontSource } from "@md-to-pdf/docx-engine";
@@ -45,7 +45,7 @@ export interface ApplicationServiceOptions
export interface PreparedDocxExport { export interface PreparedDocxExport {
request: DocxExportRequest; request: DocxExportRequest;
document: RenderedMarkdownDocument; document: RenderedMarkdown;
theme: { theme: {
manifest: ThemeManifest; manifest: ThemeManifest;
source: "bundled" | "local"; source: "bundled" | "local";
@@ -486,7 +486,7 @@ export class DocxExportService {
media: Awaited<ReturnType<typeof prepareDocxMedia>> media: Awaited<ReturnType<typeof prepareDocxMedia>>
): PandocDocxConversionInput { ): PandocDocxConversionInput {
return { return {
markdown: prepared.request.markdown, markdown: prepared.document.markdownBody,
fileName: prepared.request.fileName, fileName: prepared.request.fileName,
language: prepared.request.language, language: prepared.request.language,
exportConfig: prepared.request.exportConfig, exportConfig: prepared.request.exportConfig,
+28 -6
View File
@@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx";
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000; export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500; export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50; 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_RASTER_DPI = 300;
export const DOCX_MEDIA_CSS_DPI = 96; export const DOCX_MEDIA_CSS_DPI = 96;
export const DOCX_MEDIA_RASTER_SCALE = export const DOCX_MEDIA_RASTER_SCALE =
@@ -108,7 +109,7 @@ export type DocxMediaAlignment = z.infer<
export const docxTableLayoutSchema = z.object({ export const docxTableLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(256), 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), leftOffsetPercent: z.number().min(0).max(100),
columnWidthPercents: z columnWidthPercents: z
.array(z.number().positive().max(100)) .array(z.number().positive().max(100))
@@ -145,6 +146,13 @@ export const docxTextBlockLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000), ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(100_000), text: z.string().min(1).max(100_000),
letterSpacingPt: z.number().min(-20).max(100), 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 alignment: z
.enum(["left", "center", "right", "justify", "distribute"]) .enum(["left", "center", "right", "justify", "distribute"])
.optional(), .optional(),
@@ -162,7 +170,10 @@ export const docxListItemLayoutSchema = z.object({
ordinal: z.number().int().min(1).max(100_000), ordinal: z.number().int().min(1).max(100_000),
text: z.string().min(1).max(100_000), text: z.string().min(1).max(100_000),
depth: z.number().int().min(0).max(64), 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< export type DocxListItemLayout = z.infer<
@@ -194,11 +205,22 @@ export type DocxInlineCodeLayout = z.infer<
typeof docxInlineCodeLayoutSchema 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({ export const docxDocumentLayoutPlanSchema = z.object({
tables: z.array(docxTableLayoutSchema).max(256), tables: z.array(docxTableLayoutSchema).max(256),
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(), textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
listItems: z.array(docxListItemLayoutSchema).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< export type DocxDocumentLayoutPlan = z.infer<
@@ -208,12 +230,12 @@ export type DocxDocumentLayoutPlan = z.infer<
export const docxMediaCaptureTargetSchema = z.object({ export const docxMediaCaptureTargetSchema = z.object({
id: z.string().regex(/^docx-media-\d+$/u), id: z.string().regex(/^docx-media-\d+$/u),
kind: docxMediaKindSchema, 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 kindOrdinal: z
.number() .number()
.int() .int()
.min(1) .min(1)
.max(MAXIMUM_DOCX_RESOURCE_COUNT), .max(MAXIMUM_DOCX_MEDIA_COUNT),
altText: z.string().max(1_000), altText: z.string().max(1_000),
caption: z.string().max(1_000).optional(), caption: z.string().max(1_000).optional(),
alignment: docxMediaAlignmentSchema, alignment: docxMediaAlignmentSchema,
@@ -233,7 +255,7 @@ export type DocxMediaCaptureTarget = z.infer<
export const docxMediaCapturePlanSchema = z.object({ export const docxMediaCapturePlanSchema = z.object({
targets: z targets: z
.array(docxMediaCaptureTargetSchema) .array(docxMediaCaptureTargetSchema)
.max(MAXIMUM_DOCX_RESOURCE_COUNT), .max(MAXIMUM_DOCX_MEDIA_COUNT),
echartsErrors: z.array(z.string().max(1_000)), echartsErrors: z.array(z.string().max(1_000)),
mermaidErrors: z.array(z.string().max(1_000)), mermaidErrors: z.array(z.string().max(1_000)),
documentLayout: docxDocumentLayoutPlanSchema.optional() documentLayout: docxDocumentLayoutPlanSchema.optional()
+39
View File
@@ -3,8 +3,10 @@ import {
DOCX_MIME_TYPE, DOCX_MIME_TYPE,
DOCX_MEDIA_RASTER_SCALE, DOCX_MEDIA_RASTER_SCALE,
DOCX_PANDOC_VERSION, DOCX_PANDOC_VERSION,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MARKDOWN_LENGTH, MAXIMUM_DOCX_MARKDOWN_LENGTH,
MAXIMUM_DOCX_RESOURCE_COUNT,
createDocxFileName, createDocxFileName,
defaultExportConfig, defaultExportConfig,
docxCapabilitySchema, docxCapabilitySchema,
@@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => {
}).success }).success
).toBe(false); ).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 主题样式协议", () => { describe("DOCX 主题样式协议", () => {
+23 -1
View File
@@ -278,8 +278,14 @@ export function comparePdfSnapshotsBasic(
paragraphLayouts.every( paragraphLayouts.every(
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched, (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 const baselineEditableCoverage = hasEditableExpectation
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText) ? baselineParagraphContentExact
? 1
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
: undefined; : undefined;
const candidateEditableSimilarity = hasEditableExpectation const candidateEditableSimilarity = hasEditableExpectation
? calculateContentSimilarity( ? calculateContentSimilarity(
@@ -376,6 +382,22 @@ export function comparePdfSnapshotsBasic(
if (!primaryExpectation) { if (!primaryExpectation) {
return false; 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( return comparePdfPageSemantics(
baseline, baseline,
candidate, candidate,
@@ -131,6 +131,22 @@ try {
$pageCount = $null $pageCount = $null
try { $pageCount = $document.ComputeStatistics(2) } catch {} try { $pageCount = $document.ComputeStatistics(2) } catch {}
$document.ExportAsFixedFormat($resolvedOutput, 17) $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]@{ [pscustomobject]@{
pages = $pageCount pages = $pageCount
bytes = (Get-Item -LiteralPath $resolvedOutput).Length bytes = (Get-Item -LiteralPath $resolvedOutput).Length
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
); );
const startedAt = performance.now(); const startedAt = performance.now();
try { try {
const metadata = await backend.exportPdf({ let metadata: OfficeExportMetadata | undefined;
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
metadata = await backend.exportPdf({
client: descriptor.client, client: descriptor.client,
progId: descriptor.progId, progId: descriptor.progId,
inputPath, inputPath,
outputPath, outputPath,
timeoutMs, timeoutMs,
}); });
break;
} catch (error) {
lastError = error;
await rm(outputPath, { force: true });
if (attempt < 3) {
await new Promise<void>((resolve) => {
setTimeout(resolve, attempt * 250);
});
}
}
}
if (!metadata) {
throw lastError;
}
const outputStat = await stat(outputPath); const outputStat = await stat(outputPath);
if ( if (
outputStat.size <= 0 || outputStat.size <= 0 ||
File diff suppressed because it is too large Load Diff
+349 -20
View File
@@ -33,12 +33,35 @@ async function decodeRaster(
width: number, width: number,
height: number, height: number,
): Promise<DecodedRaster> { ): Promise<DecodedRaster> {
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 canvas = createCanvas(width, height);
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
context.fillStyle = "#ffffff"; context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height); context.fillRect(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); context.drawImage(image, 0, 0, width, height);
}
return { return {
width, width,
height, 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( function rasterBackgroundColor(
rgba: Uint8ClampedArray, rgba: Uint8ClampedArray,
_width: number, width: number,
_height: number, height: number,
): RgbColor { ): 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< const buckets = new Map<
number, number,
{ count: number; red: number; green: number; blue: number } { count: number; red: number; green: number; blue: number }
@@ -78,10 +156,12 @@ function rasterBackgroundColor(
| { count: number; red: number; green: number; blue: number } | { count: number; red: number; green: number; blue: number }
| undefined; | undefined;
for (let offset = 0; offset < rgba.length; offset += 4) { for (let offset = 0; offset < rgba.length; offset += 4) {
const red = rgba[offset] ?? 255; const pixelRed = rgba[offset] ?? 255;
const green = rgba[offset + 1] ?? 255; const pixelGreen = rgba[offset + 1] ?? 255;
const blue = rgba[offset + 2] ?? 255; const pixelBlue = rgba[offset + 2] ?? 255;
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3); const bucketKey = (pixelRed >> 3) << 10 |
(pixelGreen >> 3) << 5 |
(pixelBlue >> 3);
const bucket = buckets.get(bucketKey) ?? { const bucket = buckets.get(bucketKey) ?? {
count: 0, count: 0,
red: 0, red: 0,
@@ -89,21 +169,28 @@ function rasterBackgroundColor(
blue: 0, blue: 0,
}; };
bucket.count += 1; bucket.count += 1;
bucket.red += red; bucket.red += pixelRed;
bucket.green += green; bucket.green += pixelGreen;
bucket.blue += blue; bucket.blue += pixelBlue;
buckets.set(bucketKey, bucket); buckets.set(bucketKey, bucket);
if (!dominant || bucket.count > dominant.count) { if (!dominant || bucket.count > dominant.count) {
dominant = bucket; dominant = bucket;
} }
} }
return !dominant const dominantColor = dominant
? { red: 255, green: 255, blue: 255 } ? {
: {
red: dominant.red / dominant.count, red: dominant.red / dominant.count,
green: dominant.green / dominant.count, green: dominant.green / dominant.count,
blue: dominant.blue / dominant.count, blue: dominant.blue / dominant.count,
}; }
: border;
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
// 把深色文字拆分成了错误的“众数背景”。
return borderChannelRange <= 64 &&
colorLuminance(border) >= 220 &&
colorLuminance(dominantColor) <= colorLuminance(border) - 80
? border
: dominantColor;
} }
function rasterForegroundColor( 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<typeof rasterChromaticPalette>,
target: ReturnType<typeof rasterChromaticPalette>,
) => {
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( function clearMaskBorder(
mask: Uint8Array, mask: Uint8Array,
width: number, width: number,
@@ -427,16 +691,16 @@ export async function comparePageRasters(
!Number.isSafeInteger(height) || height <= 0) { !Number.isSafeInteger(height) || height <= 0) {
throw new Error("栅格比较尺寸必须是正整数"); throw new Error("栅格比较尺寸必须是正整数");
} }
const [baselineDecoded, candidateDecoded] = await Promise.all([ const baselineDecoded = await decodeRaster(baseline, width, height);
decodeRaster(baseline, width, height), const candidateDecoded = await decodeRaster(candidate, width, height);
decodeRaster(candidate, width, height),
]);
const pixelCount = width * height; const pixelCount = width * height;
const baselineGray = new Uint8Array(pixelCount); const baselineGray = new Uint8Array(pixelCount);
const candidateGray = new Uint8Array(pixelCount); const candidateGray = new Uint8Array(pixelCount);
const pixelDifference = new Uint8Array(pixelCount); const pixelDifference = new Uint8Array(pixelCount);
const baselineInk = new Uint8Array(pixelCount); const baselineInk = new Uint8Array(pixelCount);
const candidateInk = new Uint8Array(pixelCount); const candidateInk = new Uint8Array(pixelCount);
const baselineForegroundInk = new Uint8Array(pixelCount);
const candidateForegroundInk = new Uint8Array(pixelCount);
const baselineBackground = rasterBackgroundColor( const baselineBackground = rasterBackgroundColor(
baselineDecoded.rgba, baselineDecoded.rgba,
width, width,
@@ -447,6 +711,8 @@ export async function comparePageRasters(
width, width,
height, height,
); );
const baselineForeground = rasterForegroundColor(baselineDecoded);
const candidateForeground = rasterForegroundColor(candidateDecoded);
let absoluteError = 0; let absoluteError = 0;
let changedPixels = 0; let changedPixels = 0;
@@ -494,10 +760,34 @@ export async function comparePageRasters(
if (colorDistance(candidateColor, candidateBackground) >= 16) { if (colorDistance(candidateColor, candidateBackground) >= 16) {
candidateInk[pixelIndex] = 1; 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(baselineInk, width, height);
clearMaskBorder(candidateInk, width, height); clearMaskBorder(candidateInk, width, height);
clearMaskBorder(baselineForegroundInk, width, height);
clearMaskBorder(candidateForegroundInk, width, height);
const baselineEdges = clearMaskBorder( const baselineEdges = clearMaskBorder(
createEdgeMask(baselineGray, width, height, 40), createEdgeMask(baselineGray, width, height, 40),
width, width,
@@ -509,8 +799,27 @@ export async function comparePageRasters(
height, height,
); );
const foregroundColorDelta = colorDistance( const foregroundColorDelta = colorDistance(
rasterForegroundColor(baselineDecoded), baselineForeground,
rasterForegroundColor(candidateDecoded), 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( const backgroundColorDelta = colorDistance(
baselineBackground, baselineBackground,
@@ -537,6 +846,13 @@ export async function comparePageRasters(
height, height,
spatialTolerancePx, spatialTolerancePx,
), ),
foregroundInkIou: calculateSpatiallyTolerantIou(
baselineForegroundInk,
candidateForegroundInk,
width,
height,
spatialTolerancePx,
),
edgeIou: calculateSpatiallyTolerantIou( edgeIou: calculateSpatiallyTolerantIou(
baselineEdges, baselineEdges,
candidateEdges, candidateEdges,
@@ -546,6 +862,19 @@ export async function comparePageRasters(
), ),
backgroundColorDelta, backgroundColorDelta,
foregroundColorDelta, foregroundColorDelta,
baselineForegroundChroma: colorChroma(baselineForeground),
candidateForegroundChroma: colorChroma(candidateForeground),
foregroundLuminanceDelta: Math.abs(
colorLuminance(baselineForeground) -
colorLuminance(candidateForeground),
),
dominantForegroundColorDelta,
...(chromaticForegroundColorDelta !== undefined
? { chromaticForegroundColorDelta }
: {}),
...(chromaticPaletteDelta !== undefined
? { chromaticPaletteDelta }
: {}),
}; };
return { return {
metrics, metrics,
@@ -14,7 +14,27 @@ import type {
VisualDiffThresholds, VisualDiffThresholds,
} from "./types.js"; } 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 { function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex"); return createHash("sha256").update(bytes).digest("hex");
@@ -48,6 +68,73 @@ function collectFonts(
].sort(); ].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( async function cropRaster(
raster: PdfPageRaster, raster: PdfPageRaster,
bounds: PdfPointBounds, bounds: PdfPointBounds,
@@ -55,22 +142,28 @@ async function cropRaster(
const pixelsPerPoint = raster.dpi / 72; const pixelsPerPoint = raster.dpi / 72;
const left = Math.max( const left = Math.max(
0, 0,
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint), Math.floor(
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
),
); );
const top = Math.max( const top = Math.max(
0, 0,
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint), Math.floor(
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
),
); );
const right = Math.min( const right = Math.min(
raster.widthPx, raster.widthPx,
Math.ceil( 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( const bottom = Math.min(
raster.heightPx, raster.heightPx,
Math.ceil( 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); const widthPx = Math.max(1, right - left);
@@ -91,6 +184,7 @@ async function cropRaster(
widthPx, widthPx,
heightPx, heightPx,
); );
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
const png = Uint8Array.from(canvas.toBuffer("image/png")); const png = Uint8Array.from(canvas.toBuffer("image/png"));
return { return {
widthPx, widthPx,
@@ -98,6 +192,7 @@ async function cropRaster(
dpi: raster.dpi, dpi: raster.dpi,
sha256: sha256(png), sha256: sha256(png),
png, png,
rgba,
}; };
} }
@@ -109,12 +204,25 @@ async function padRaster(
if (raster.widthPx === widthPx && raster.heightPx === heightPx) { if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
return raster; return raster;
} }
const source = await loadImage(Buffer.from(raster.png));
const canvas = createCanvas(widthPx, heightPx); const canvas = createCanvas(widthPx, heightPx);
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
context.fillStyle = "#ffffff"; context.fillStyle = "#ffffff";
context.fillRect(0, 0, widthPx, heightPx); context.fillRect(0, 0, widthPx, heightPx);
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); context.drawImage(source, 0, 0);
}
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
const png = Uint8Array.from(canvas.toBuffer("image/png")); const png = Uint8Array.from(canvas.toBuffer("image/png"));
return { return {
widthPx, widthPx,
@@ -122,6 +230,7 @@ async function padRaster(
dpi: raster.dpi, dpi: raster.dpi,
sha256: sha256(png), sha256: sha256(png),
png, png,
rgba,
}; };
} }
@@ -129,6 +238,7 @@ function localRasterIssues(
paragraphIndex: number, paragraphIndex: number,
lineIndex: number, lineIndex: number,
blockKind: string | undefined, blockKind: string | undefined,
hasInlineCode: boolean,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>, metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds, thresholds: VisualDiffThresholds,
textReflow: boolean, textReflow: boolean,
@@ -136,9 +246,12 @@ function localRasterIssues(
const shapeFailed = const shapeFailed =
metrics.inkIou < thresholds.minInkIou || metrics.inkIou < thresholds.minInkIou ||
metrics.edgeIou < thresholds.minEdgeIou; metrics.edgeIou < thresholds.minEdgeIou;
const neutralTinyGlyphAntialiasEquivalent =
isNeutralTinyGlyphAntialiasEquivalent(metrics);
const colorFailed = const colorFailed =
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta || metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta; (metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
!neutralTinyGlyphAntialiasEquivalent);
const rawPixelsFailed = const rawPixelsFailed =
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError || metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio; metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
@@ -150,8 +263,10 @@ function localRasterIssues(
metrics, metrics,
textReflow, textReflow,
blockKind, blockKind,
hasInlineCode,
); );
const failed = colorFailed || (!textReflow && ( const failed = (colorFailed && !crossEngineRasterEquivalent) ||
(!textReflow && (
(shapeFailed && !crossEngineRasterEquivalent) || (shapeFailed && !crossEngineRasterEquivalent) ||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent) (rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
)); ));
@@ -174,12 +289,81 @@ function localRasterIssues(
foregroundColorDelta: metrics.foregroundColorDelta, foregroundColorDelta: metrics.foregroundColorDelta,
antialiasEquivalent, antialiasEquivalent,
crossEngineRasterEquivalent, crossEngineRasterEquivalent,
neutralTinyGlyphAntialiasEquivalent,
textReflow, textReflow,
}, },
}, },
]; ];
} }
function isNeutralTinyGlyphAntialiasEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
// 极细的中性标点(例如表格占位长横线)在 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<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5;
}
function isChromaticPaletteEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
) {
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<PdfSemanticBlockVisualLineComparison["metrics"]>,
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 会用不同的灰阶覆盖率栅格化同一套嵌入字形。 * Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为 * 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
@@ -189,6 +373,7 @@ export function isCrossEngineRasterEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>, metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
textReflow: boolean, textReflow: boolean,
blockKind?: string, blockKind?: string,
hasInlineCode = false,
): boolean { ): boolean {
if (textReflow) { if (textReflow) {
return false; return false;
@@ -215,10 +400,27 @@ export function isCrossEngineRasterEquivalent(
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 && Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
widthDelta <= 2 && widthDelta <= 2 &&
heightDelta <= 1; heightDelta <= 1;
const neutralTinyGlyphAntialiasEquivalent =
isNeutralTinyGlyphAntialiasEquivalent(metrics);
const colorEquivalent = const colorEquivalent =
neutralTinyGlyphAntialiasEquivalent ||
(
metrics.backgroundColorDelta <= 4 && metrics.backgroundColorDelta <= 4 &&
metrics.foregroundColorDelta <= (metrics.foregroundColorDelta <=
(blockKind === "list-item" ? 4.5 : 4); (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 = const topologyEquivalent =
(preciseGeometryEquivalent && (preciseGeometryEquivalent &&
metrics.inkIou >= 0.99 && metrics.inkIou >= 0.99 &&
@@ -247,7 +449,8 @@ export function isCrossEngineRasterEquivalent(
(preciseGeometryEquivalent && (preciseGeometryEquivalent &&
metrics.edgeIou >= 0.97 && metrics.edgeIou >= 0.97 &&
metrics.inkIou >= 0.97); metrics.inkIou >= 0.97);
return colorEquivalent && topologyEquivalent; return inlineCodeShadingEquivalent ||
(colorEquivalent && topologyEquivalent);
} }
export async function comparePdfSemanticBlockVisuals( export async function comparePdfSemanticBlockVisuals(
@@ -270,14 +473,41 @@ export async function comparePdfSemanticBlockVisuals(
"paragraph", "paragraph",
"list-item", "list-item",
"block-quote", "block-quote",
"code-block",
].includes(paragraph.expectation.blockKind ?? "paragraph"); ].includes(paragraph.expectation.blockKind ?? "paragraph");
const comparableLineCount = Math.min( const comparableLineCount = Math.min(
paragraph.baseline.visualLines.length, paragraph.baseline.visualLines.length,
paragraph.candidate.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 ( if (
!paragraph.baseline.matched || !hasUsableVisualMapping(paragraph.baseline) ||
!paragraph.candidate.matched || !hasUsableVisualMapping(paragraph.candidate) ||
comparableLineCount === 0 comparableLineCount === 0
) { ) {
issues.push({ issues.push({
@@ -286,10 +516,7 @@ export async function comparePdfSemanticBlockVisuals(
message: `${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`, message: `${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
details: { paragraphIndex: paragraph.expectation.index }, details: { paragraphIndex: paragraph.expectation.index },
}); });
} else if ( } else if (equalLineSegmentation) {
paragraph.baseline.visualLines.length ===
paragraph.candidate.visualLines.length
) {
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) { for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
const baselineLine = paragraph.baseline.visualLines[lineIndex]; const baselineLine = paragraph.baseline.visualLines[lineIndex];
const candidateLine = paragraph.candidate.visualLines[lineIndex]; const candidateLine = paragraph.candidate.visualLines[lineIndex];
@@ -327,11 +554,28 @@ export async function comparePdfSemanticBlockVisuals(
cropRaster(baselinePage.raster, baselineLine.bounds), cropRaster(baselinePage.raster, baselineLine.bounds),
cropRaster(candidatePage.raster, candidateLine.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 baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? ""; const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
const textReflow = flowTextBlock && const textReflow = flowTextBlock &&
baselineLineText !== candidateLineText; baselineLineText !== candidateLineText;
const result = flowTextBlock && !textReflow 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, { ? await comparePageRasters(baselineCrop, candidateCrop, {
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
spatialTolerancePx: thresholds.spatialTolerancePx, spatialTolerancePx: thresholds.spatialTolerancePx,
@@ -358,10 +602,19 @@ export async function comparePdfSemanticBlockVisuals(
thresholds.pixelDifferenceThreshold, 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( const lineIssues = localRasterIssues(
paragraph.expectation.index, paragraph.expectation.index,
lineIndex, lineIndex,
paragraph.expectation.blockKind, paragraph.expectation.blockKind,
paragraph.expectation.hasInlineCode === true,
result.metrics, result.metrics,
thresholds, thresholds,
textReflow, textReflow,
@@ -376,6 +629,139 @@ export async function comparePdfSemanticBlockVisuals(
issues: lineIssues, 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({ comparisons.push({
expectation: paragraph.expectation, expectation: paragraph.expectation,
+97 -6
View File
@@ -7,7 +7,7 @@ import type {
VisualPageSemanticExpectation, VisualPageSemanticExpectation,
} from "./types.js"; } 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([ const CJK_RADICAL_VARIANTS = new Map([
["⺠", "民"], ["⺠", "民"],
["⻅", "见"], ["⻅", "见"],
@@ -16,6 +16,10 @@ const CJK_RADICAL_VARIANTS = new Map([
["⻔", "门"], ["⻔", "门"],
["⻚", "页"], ["⻚", "页"],
["⻛", "风"], ["⻛", "风"],
["⻝", "食"],
["⻣", "骨"],
["⻋", "车"],
["⻬", "齐"],
]); ]);
const PAGE_NUMBER_PATTERNS = [ const PAGE_NUMBER_PATTERNS = [
/^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u, /^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u,
@@ -30,6 +34,16 @@ export function normalizePdfText(value: string): string {
.replace(/[\u2e80-\u2eff]/gu, (character) => .replace(/[\u2e80-\u2eff]/gu, (character) =>
CJK_RADICAL_VARIANTS.get(character) ?? 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(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ") .replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ") .replace(/[ \t]+/gu, " ")
@@ -133,6 +147,63 @@ function median(values: readonly number[]): number {
return ((sorted[middle - 1] ?? value) + value) / 2; 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 { function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
let result = ""; let result = "";
let previous: PdfTextItemSnapshot | undefined; let previous: PdfTextItemSnapshot | undefined;
@@ -181,6 +252,9 @@ export function aggregatePdfTextLines(
sourceItems: readonly PdfTextItemSnapshot[], sourceItems: readonly PdfTextItemSnapshot[],
pageHeightPt: number, pageHeightPt: number,
): PdfTextLineSnapshot[] { ): PdfTextLineSnapshot[] {
const sourceOrder = new Map(
sourceItems.map((item, index) => [item, index] as const),
);
const items = sourceItems const items = sourceItems
.filter((item) => item.normalizedText.length > 0) .filter((item) => item.normalizedText.length > 0)
.sort( .sort(
@@ -188,11 +262,14 @@ export function aggregatePdfTextLines(
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x, left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
); );
const typicalHeight = median(items.map((item) => item.bounds.height)); const typicalHeight = median(items.map((item) => item.bounds.height));
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15); // Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
const groups: PdfTextItemSnapshot[][] = []; // 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
const initialGroups: PdfTextItemSnapshot[][] = [];
for (const item of items) { for (const item of items) {
const lastGroup = groups.at(-1); const lastGroup = initialGroups.at(-1);
const lastBaseline = lastGroup const lastBaseline = lastGroup
? median(lastGroup.map((entry) => entry.baselineY)) ? median(lastGroup.map((entry) => entry.baselineY))
: undefined; : undefined;
@@ -203,13 +280,27 @@ export function aggregatePdfTextLines(
) { ) {
lastGroup.push(item); lastGroup.push(item);
} else { } else {
groups.push([item]); initialGroups.push([item]);
} }
} }
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
return groups.map((group) => { return groups.map((group) => {
const sortedItems = [...group].sort( 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 bounds = unionBounds(sortedItems);
const normalizedText = joinLineItems(sortedItems); const normalizedText = joinLineItems(sortedItems);
@@ -71,6 +71,12 @@ export type EditableParagraphBlockKind =
export interface EditableParagraphExpectation { export interface EditableParagraphExpectation {
index: number; index: number;
text: string; text: string;
mathCharacterIndexes?: number[];
hardBreakSegments?: string[];
hasInlineCode?: boolean;
tableGroupId?: string;
tableRowIndex?: number;
tableColumnIndex?: number;
styleId?: string; styleId?: string;
role: EditableParagraphRole; role: EditableParagraphRole;
blockKind?: EditableParagraphBlockKind; blockKind?: EditableParagraphBlockKind;
@@ -144,6 +150,7 @@ export interface PdfPageRaster {
dpi: number; dpi: number;
sha256: string; sha256: string;
png: Uint8Array; png: Uint8Array;
rgba?: Uint8ClampedArray;
} }
export interface PdfPageSnapshot { export interface PdfPageSnapshot {
@@ -287,9 +294,16 @@ export interface PdfRasterDiffMetrics {
meanAbsoluteError: number; meanAbsoluteError: number;
changedPixelRatio: number; changedPixelRatio: number;
inkIou: number; inkIou: number;
foregroundInkIou: number;
edgeIou: number; edgeIou: number;
backgroundColorDelta: number; backgroundColorDelta: number;
foregroundColorDelta: number; foregroundColorDelta: number;
baselineForegroundChroma?: number;
candidateForegroundChroma?: number;
foregroundLuminanceDelta?: number;
dominantForegroundColorDelta?: number;
chromaticForegroundColorDelta?: number;
chromaticPaletteDelta?: number;
} }
export interface ComparePageRasterOptions { export interface ComparePageRasterOptions {
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
await rm(directory, { recursive: true, force: true }); 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 适配器视觉编排", () => { describe("PDF 适配器视觉编排", () => {
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => {
meanAbsoluteError: 0, meanAbsoluteError: 0,
changedPixelRatio: 0, changedPixelRatio: 0,
inkIou: 1, inkIou: 1,
foregroundInkIou: 1,
edgeIou: 1, edgeIou: 1,
backgroundColorDelta: 0, backgroundColorDelta: 0,
foregroundColorDelta: 0, foregroundColorDelta: 0,
@@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => {
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1); 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 () => { it("量化位移并生成稳定叠加图与热力图", async () => {
const result = await comparePageRasters(raster(10), raster(20), { const result = await comparePageRasters(raster(10), raster(20), {
spatialTolerancePx: 0, spatialTolerancePx: 0,
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { import {
createPdfVisualDiffReport, createPdfVisualDiffReport,
getBlockingVisualDiffIssues, getBlockingVisualDiffIssues,
isCrossEngineRasterEquivalent,
renderPdfVisualDiffHtml, renderPdfVisualDiffHtml,
serializePdfVisualDiffJson, serializePdfVisualDiffJson,
type PdfDocumentSnapshot, 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 { function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
return { return {
schemaVersion: 1, 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 () => { it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
const pageNumber = (text: string): PdfTextLineSnapshot => ({ const pageNumber = (text: string): PdfTextLineSnapshot => ({
text, text,
@@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => {
.toMatchObject({ inkIou: 1, edgeIou: 1 }); .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 () => { it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
const report = await createPdfVisualDiffReport( const report = await createPdfVisualDiffReport(
snapshot("baseline", raster("#111111")), snapshot("baseline", raster("#111111")),
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createCanvas } from "@napi-rs/canvas";
import { import {
comparePdfSemanticBlockVisuals,
isCrossEngineRasterEquivalent, isCrossEngineRasterEquivalent,
type PdfRasterMetrics, type PdfRasterMetrics,
} from "../src/index.js"; } 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("跨引擎字形栅格等价", () => { 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("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => { it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true); expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
expect( expect(
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
false, false,
), ),
).toBe(true); ).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( expect(
isCrossEngineRasterEquivalent( isCrossEngineRasterEquivalent(
metrics({ inkIou: 1, edgeIou: 0.946 }), metrics({ inkIou: 1, edgeIou: 0.946 }),
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
"list-item", "list-item",
), ),
).toBe(true); ).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( expect(
isCrossEngineRasterEquivalent( isCrossEngineRasterEquivalent(
metrics({ metrics({
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
).toBe(true); ).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("拒绝回流、颜色、几何或轮廓变化", () => { it("拒绝回流、颜色、几何或轮廓变化", () => {
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false); expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
expect( expect(
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
expect( expect(
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false), isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
).toBe(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( expect(
isCrossEngineRasterEquivalent( isCrossEngineRasterEquivalent(
metrics({ metrics({
@@ -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 中的传统户部件归一为简体字符", () => { it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
expect(normalizePdfText("戶⼾")).toBe("户户"); 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 自动列表装饰符", () => { it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
@@ -98,6 +98,64 @@ local function replace_code_block(block)
return pandoc.Para { image } return pandoc.Para { image }
end 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 == "<br>"
or normalized == "<br/>"
or normalized == "<br />" 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) local function append_inlines(target, value)
target:extend(pandoc.Inlines(value)) target:extend(pandoc.Inlines(value))
end end
@@ -167,7 +225,10 @@ local function validate_counts()
error( error(
"DOCX media map contains unused " "DOCX media map contains unused "
.. kind .. kind
.. " resources" .. " resources: consumed "
.. tostring(counters[kind])
.. " of "
.. tostring(#media_map[kind])
) )
end end
end end
@@ -176,8 +237,14 @@ end
return { return {
Pandoc = function(document) Pandoc = function(document)
local transformed = document:walk { local transformed = document:walk {
Table = replace_table_breaks
}
transformed = transformed:walk {
Image = replace_image, 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 if structure_plan.titlePolicy.metadataTitle == "suppress" then
transformed.meta.title = nil transformed.meta.title = nil
@@ -424,7 +424,7 @@ for (const theme of themes) {
let conversion; let conversion;
try { try {
conversion = await converter.convert({ conversion = await converter.convert({
markdown, markdown: rendered.markdownBody,
fileName: `${theme.id}.md`, fileName: `${theme.id}.md`,
language: rendered.metadata.language, language: rendered.metadata.language,
exportConfig, exportConfig,
@@ -433,7 +433,7 @@ for (const theme of themes) {
metadata: rendered.metadata, metadata: rendered.metadata,
semanticDocument: rendered.semanticDocument, semanticDocument: rendered.semanticDocument,
fonts: readThemeFonts(theme), fonts: readThemeFonts(theme),
media: createMedia(markdown) media: createMedia(rendered.markdownBody)
}); });
} catch (error) { } catch (error) {
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, { throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
@@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable<
const WORDPROCESSING_DRAWING_NAMESPACE = const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; "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"; const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
// Chromium 预留 6ptWord/WPS 的分节承载段落还需要额外分页保留量。 // Chromium 预留 6ptWord/WPS 的分节承载段落还需要额外分页保留量。
const COVER_PAGE_BREAK_SAFETY_PT = 6; const COVER_PAGE_BREAK_SAFETY_PT = 6;
@@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens(
const inlineCodeLayouts = documentLayout.inlineCodes ?? []; const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
let inlineCodeCursor = 0; let inlineCodeCursor = 0;
let count = 0; let count = 0;
for (const run of Array.from( const runs = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "r") document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
)) { );
for (const [runIndex, run] of runs.entries()) {
const properties = firstDirectChild( const properties = firstDirectChild(
run, run,
WORD_NAMESPACE, WORD_NAMESPACE,
@@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens(
const isSourceCode = paragraphStyle const isSourceCode = paragraphStyle
? wordAttribute(paragraphStyle, "val") === "SourceCode" ? wordAttribute(paragraphStyle, "val") === "SourceCode"
: false; : 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 const syntaxSlot = isSourceCode && styleId
? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "") ? resolvePandocSyntaxStyleSlot(
styleId,
run.textContent ?? "",
{
htmlTagName: isHtmlTagName,
objectPropertyKey: isObjectPropertyKey
}
)
: undefined; : undefined;
const token = isSourceCode const token = isSourceCode
? syntaxSlot ? syntaxSlot
@@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens(
return count; 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( function applyTableCellToken(
cell: XmlElement, cell: XmlElement,
token: DocxSlotStyleToken token: DocxSlotStyleToken
@@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry(
table: XmlElement, table: XmlElement,
properties: XmlElement, properties: XmlElement,
layout: DocxDocumentLayoutPlan["tables"][number], 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, 1,
Math.round(contentWidthTwips * layout.widthPercent / 100) Math.round(contentWidthTwips * layout.widthPercent / 100)
); );
const indentTwips = Math.max( const tableWidthTwips = Math.max(
0, 1,
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) Math.min(
requestedTableWidthTwips,
Math.max(1, contentWidthTwips - indentTwips)
)
); );
const columnWidths = measuredColumnWidths( const columnWidths = measuredColumnWidths(
tableWidthTwips, tableWidthTwips,
@@ -2315,7 +2379,11 @@ function applyTables(
table, table,
properties, properties,
measuredLayout, measuredLayout,
contentWidthTwips contentWidthTwips,
pointsToTwips(Math.max(
tableCellToken?.paddingPt?.left ?? 0,
tableHeaderToken?.paddingPt?.left ?? 0
))
); );
} else { } else {
removeDirectChildren( removeDirectChildren(
@@ -2427,8 +2495,15 @@ function applyTables(
WORD_NAMESPACE, WORD_NAMESPACE,
"p" "p"
)) { )) {
const properties = paragraphProperties(paragraph);
const wordWrap = ensureFirstElement(
properties,
"wordWrap",
"w:wordWrap"
);
setWordAttribute(wordWrap, "val", "1");
const spacing = ensureFirstElement( const spacing = ensureFirstElement(
paragraphProperties(paragraph), properties,
"spacing", "spacing",
"w:spacing" "w:spacing"
); );
@@ -2436,7 +2511,7 @@ function applyTables(
setWordAttribute(spacing, "after", "0"); setWordAttribute(spacing, "after", "0");
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) { for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
const automaticSpacing = ensureFirstElement( const automaticSpacing = ensureFirstElement(
paragraphProperties(paragraph), properties,
property, property,
`w:${property}` `w:${property}`
); );
@@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents(
String(Math.min(targetLeft, hanging)) 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; appliedCount += 1;
} }
return appliedCount; 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( function applyMeasuredTextBlockLineBreaks(
document: XmlDocument, document: XmlDocument,
documentLayout: DocxDocumentLayoutPlan documentLayout: DocxDocumentLayoutPlan
@@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks(
"line", "line",
String(pointsToTwips(measured.linePitchPt)) String(pointsToTwips(measured.linePitchPt))
); );
setWordAttribute(spacing, "lineRule", "exact"); setWordAttribute(
spacing,
"lineRule",
paragraphHasInlineCode(paragraph) ? "atLeast" : "exact"
);
} }
for (const offset of [...measuredLineBreakOffsetsForParagraph( for (const offset of [...measuredLineBreakOffsetsForParagraph(
paragraph, paragraph,
@@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks(
return appliedCount; 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) { function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
const ids = new Set<string>(); const ids = new Set<string>();
for (const entry of tokens.slots) { 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( export function finalizeGeneratedDocxStructure(
content: Uint8Array, content: Uint8Array,
plan: PandocStructurePlan, plan: PandocStructurePlan,
@@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure(
containers.editableContainerTables.size > 0, containers.editableContainerTables.size > 0,
usesEvenAndOddPages usesEvenAndOddPages
); );
normalizePandocAlertParagraphStyles(body, documentLayout);
disableAutomaticCharacterSpacing(document); disableAutomaticCharacterSpacing(document);
const final = finalSection(body); const final = finalSection(body);
applyMeasuredTextBlockLineBreaks(document, documentLayout); applyMeasuredTextBlockLineBreaks(document, documentLayout);
@@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure(
); );
applyMeasuredTextBlockAlignments(document, documentLayout); applyMeasuredTextBlockAlignments(document, documentLayout);
applyDirectCharacterStyleTokens(document, tokens, documentLayout); applyDirectCharacterStyleTokens(document, tokens, documentLayout);
applyMeasuredEmojiRunColors(document, documentLayout);
stabilizeInlineCodeParagraphLineRules(document);
stabilizeTableMathParagraphAlignment(document);
const mediaDrawingCount = normalizeMediaParagraphs( const mediaDrawingCount = normalizeMediaParagraphs(
document, document,
tokens, tokens,
+25 -3
View File
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
referenceCacheSize?: number; referenceCacheSize?: number;
} }
export interface PandocDocxConversionDiagnostics {
outcome: string;
exitCode: number | null;
stderr: string;
}
export class PandocDocxConversionError extends Error { export class PandocDocxConversionError extends Error {
constructor( constructor(
readonly code: Extract< readonly code: Extract<
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
| "DOCX_OUTPUT_INVALID" | "DOCX_OUTPUT_INVALID"
>, >,
message: string, message: string,
options?: ErrorOptions options?: ErrorOptions & {
diagnostics?: PandocDocxConversionDiagnostics;
}
) { ) {
super(message, options); super(message, options);
this.name = "PandocDocxConversionError"; this.name = "PandocDocxConversionError";
this.diagnostics = options?.diagnostics;
} }
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
}
function processDiagnostics(
result: Awaited<ReturnType<PandocProcessRunner>>
): PandocDocxConversionDiagnostics {
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr.trim().slice(-16 * 1024)
};
} }
function ensureOutputLimit(value: number | undefined) { function ensureOutputLimit(value: number | undefined) {
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
return [ return [
paths.markdown, paths.markdown,
"--from", "--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", "--to",
"docx", "docx",
"--standalone", "--standalone",
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
processResult.outcome === "not-found" processResult.outcome === "not-found"
? "DOCX_RUNTIME_NOT_FOUND" ? "DOCX_RUNTIME_NOT_FOUND"
: "DOCX_GENERATION_FAILED", : "DOCX_GENERATION_FAILED",
"Pandoc 未能生成 DOCX" "Pandoc 未能生成 DOCX",
{ diagnostics: processDiagnostics(processResult) }
); );
} }
+2 -2
View File
@@ -2,8 +2,8 @@ import {
DOCX_MEDIA_CSS_DPI, DOCX_MEDIA_CSS_DPI,
MAXIMUM_DOCX_MEDIA_BYTES, MAXIMUM_DOCX_MEDIA_BYTES,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_PIXELS, MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES, MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
type DocxMediaKind, type DocxMediaKind,
type DocxDocumentLayoutPlan, type DocxDocumentLayoutPlan,
@@ -101,7 +101,7 @@ export function preparePandocMedia(
if (media.echartsErrors.length || media.mermaidErrors.length) { if (media.echartsErrors.length || media.mermaidErrors.length) {
throw new Error("DOCX 图表渲染存在错误,已中止转换"); throw new Error("DOCX 图表渲染存在错误,已中止转换");
} }
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) { if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
throw new Error("DOCX 媒体数量超过限制"); throw new Error("DOCX 媒体数量超过限制");
} }
@@ -139,6 +139,19 @@ export function transformSettingsXml(
) { ) {
const document = parseXmlPart(content, "word/settings.xml"); const document = parseXmlPart(content, "word/settings.xml");
const settings = document.documentElement!; 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( removeDirectChildren(
settings, settings,
WORD_NAMESPACE, WORD_NAMESPACE,
+32 -8
View File
@@ -467,8 +467,13 @@ function applyTokenParagraphStyle(
(token.paddingPt?.right ?? 0) > 0 || (token.paddingPt?.right ?? 0) > 0 ||
(token.borders?.right?.widthPt ?? 0) > 0 (token.borders?.right?.widthPt ?? 0) > 0
? (token.rightIndentPt ?? 0) + ? (token.rightIndentPt ?? 0) +
(token.paddingPt?.right ?? 0) + // 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
(token.borders?.right?.widthPt ?? 0) // 若再把 padding/border 写进 w:indWord/WPS 会重复扣减
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
(horizontalPaddingThroughBorderSpace && token.borders?.right
? 0
: (token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0))
: undefined; : undefined;
if ( if (
token.firstLineIndentPt !== undefined || token.firstLineIndentPt !== undefined ||
@@ -1191,12 +1196,23 @@ function applyTokenStyles(
binding.styleId === "ImageCaption" binding.styleId === "ImageCaption"
? { ...entry.style, alignment: "center" as const } ? { ...entry.style, alignment: "center" as const }
: entry.style; : entry.style;
applyTokenParagraphStyle( const paragraphProperties = ensureDirectElement(
ensureDirectElement(
target, target,
WORD_NAMESPACE, WORD_NAMESPACE,
"w:pPr" "w:pPr"
), );
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
if (binding.styleId === "SourceCode") {
removeDirectChildren(
paragraphProperties,
WORD_NAMESPACE,
"shd"
);
}
applyTokenParagraphStyle(
paragraphProperties,
paragraphToken, paragraphToken,
fallbackFontSizeForSlot(slot, fallback), fallbackFontSizeForSlot(slot, fallback),
binding.styleId === "SourceCode" binding.styleId === "SourceCode"
@@ -1205,12 +1221,20 @@ function applyTokenStyles(
const runToken = binding.styleId === "SourceCode" const runToken = binding.styleId === "SourceCode"
? slots.get("code-block-text")?.style ?? entry.style ? slots.get("code-block-text")?.style ?? entry.style
: entry.style; : entry.style;
applyTokenRunStyle( const runProperties = ensureDirectElement(
ensureDirectElement(
target, target,
WORD_NAMESPACE, WORD_NAMESPACE,
"w:rPr" "w:rPr"
), );
if (binding.styleId === "SourceCode") {
removeDirectChildren(
runProperties,
WORD_NAMESPACE,
"shd"
);
}
applyTokenRunStyle(
runProperties,
runToken, runToken,
fallbackFontsForSlot(slot, fallback), fallbackFontsForSlot(slot, fallback),
binding.styleId === "SourceCode" || binding.styleId === "SourceCode" ||
+11 -3
View File
@@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly<
export function resolvePandocSyntaxStyleSlot( export function resolvePandocSyntaxStyleSlot(
styleId: string, styleId: string,
text = "" text = "",
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
): DocxStyleSlotName | "code-block-text" | undefined { ): DocxStyleSlotName | "code-block-text" | undefined {
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) { if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
return undefined; return undefined;
} }
if (context.htmlTagName) {
return "code-token-name";
}
if (context.objectPropertyKey) {
return "code-token-attribute";
}
if ( if (
styleId === "NormalTok" || styleId === "NormalTok" ||
styleId === "OtherTok" || 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-token-punctuation"
: "code-block-text"; : "code-block-text";
} }
@@ -22,6 +22,8 @@ const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/main"; "http://schemas.openxmlformats.org/drawingml/2006/main";
const picture = const picture =
"http://schemas.openxmlformats.org/drawingml/2006/picture"; "http://schemas.openxmlformats.org/drawingml/2006/picture";
const math =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
function generatedFixture() { function generatedFixture() {
const baseline = readReferenceDocxPackage( const baseline = readReferenceDocxPackage(
@@ -38,6 +40,14 @@ function generatedFixture() {
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` + `<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` + `<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` + `<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>&lt;</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="KeywordTok"/></w:rPr><w:t>br</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>&gt;</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
`<w:r><w:rPr><w:rStyle w:val="NormalTok"/></w:rPr><w:t xml:space="preserve"> method</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="OperatorTok"/></w:rPr><w:t>:</w:t></w:r>` +
`<w:r><w:rPr><w:rStyle w:val="StringTok"/></w:rPr><w:t xml:space="preserve"> "POST"</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` + `<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` + `<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` + `<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
@@ -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", slot: "table-header",
source: "computed-css", source: "computed-css",
@@ -292,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
documentXml.match( documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu /<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
) )
).toHaveLength(2); ).toHaveLength(1);
expect(
documentXml.match(
/<w:spacing[^>]*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:fill="17324D"/gu)).toHaveLength(2);
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2); expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
expect( expect(
@@ -304,6 +359,12 @@ describe("生成 DOCX 结构收口", () => {
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>'); expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>'); expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
expect(documentXml).toContain("<w:cantSplit/>"); expect(documentXml).toContain("<w:cantSplit/>");
expect(documentXml).toMatch(
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>&lt;<\/w:t>[\s\S]*?<w:color w:val="22863A"\/>[\s\S]*?<w:t>br<\/w:t>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>&gt;<\/w:t>[\s\S]*?<\/w:p>/u
);
expect(documentXml).toMatch(
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:rStyle w:val="NormalTok"\/>[\s\S]*?<w:color w:val="005CC5"\/>[\s\S]*?<w:t xml:space="preserve"> method<\/w:t>[\s\S]*?<\/w:p>/u
);
expect(documentXml).toContain( expect(documentXml).toContain(
'<w:tab w:val="right" w:pos="9806"/>' '<w:tab w:val="right" w:pos="9806"/>'
); );
@@ -389,6 +450,98 @@ describe("生成 DOCX 结构收口", () => {
); );
}); });
it("含行内代码的实测行距允许 Word 扩展行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"<w:sectPr>",
'<w:p><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r><w:r><w:t>后缀</w:t></w:r></w:p><w:sectPr>'
);
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><\/w:t>[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
);
});
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
"<w:sectPr>",
'<w:p><w:pPr><w:spacing w:line="312" w:lineRule="exact"/></w:pPr><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r></w:p><w:sectPr>'
);
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]*?<w:rStyle w:val="VerbatimChar"\/>/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(
"<w:p><w:r><w:t>B</w:t></w:r></w:p>",
'<w:p><w:pPr><w:jc w:val="left"/></w:pPr><m:oMath><m:r><m:t>≥0.80</m:t></m:r></m:oMath></w:p>'
);
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:jc w:val="left"\/>[\s\S]*?<m:oMath>[\s\S]*?<m:t>0\.80<\/m:t>[\s\S]*?<\/m:oMath><w:r><w:rPr><w:noProof\/><\/w:rPr><w:t xml:space="preserve">\u200B<\/w:t><\/w:r>/u
);
});
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => { it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
const fixture = readGeneratedDocxPackage(generatedFixture()); const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode( const documentXml = decoder.decode(
@@ -508,6 +661,7 @@ describe("生成 DOCX 结构收口", () => {
'<w:tblInd w:w="495" w:type="dxa"/>' '<w:tblInd w:w="495" w:type="dxa"/>'
); );
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>'); expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
expect(documentXml).toContain('<w:wordWrap w:val="1"/>');
expect(documentXml).toContain('<w:gridCol w:w="2675"/>'); expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
expect(documentXml).toContain('<w:gridCol w:w="6240"/>'); expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
expect( expect(
@@ -525,6 +679,112 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain('<w:jc w:val="distribute"/>'); expect(documentXml).toContain('<w:jc w:val="distribute"/>');
}); });
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(
'<w:tblW w:w="9906" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:gridCol w:w="3962"/>');
expect(documentXml).toContain('<w:gridCol w:w="1980"/>');
});
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
'<w:p><w:r><w:t>⭐⭐⭐⭐</w:t></w:r></w:p>'
);
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:r><w:rPr><w:color w:val="E7BF36"\/><\/w:rPr><w:t><\/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(
'<w:tblInd w:w="615" w:type="dxa"/>'
);
});
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => { it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
const result = finalizeGeneratedDocxStructure( const result = finalizeGeneratedDocxStructure(
generatedFixture(), 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(
"<w:sectPr>",
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:t>Caution</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="BodyText"/></w:pPr><w:r><w:t>警告正文</w:t></w:r></w:p>` +
`<w:sectPr>`
))
);
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<w:pPr><w:pStyle w:val="BlockText"\/>[\s\S]*?<w:t><\/w:t>/u
);
});
it("按最终页面内容区重算右置百分比容器缩进", () => { it("按最终页面内容区重算右置百分比容器缩进", () => {
const relativeTokens: DocxThemeTokenSet = { const relativeTokens: DocxThemeTokenSet = {
...tokens, ...tokens,
@@ -127,7 +127,7 @@ describe("Pandoc DOCX 转换器", () => {
expect(arguments_).toContain("--data-dir"); expect(arguments_).toContain("--data-dir");
expect(arguments_).toContain("--resource-path"); expect(arguments_).toContain("--resource-path");
expect(argumentAfter(arguments_, "--from")).toBe( 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( expect(
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
@@ -219,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
code: "DOCX_OUTPUT_INVALID" code: "DOCX_OUTPUT_INVALID"
} satisfies Partial<PandocDocxConversionError>); } satisfies Partial<PandocDocxConversionError>);
}); });
it("进程失败时保留内部诊断但保持稳定的对外错误", async () => {
const failedRunner = vi.fn<PandocProcessRunner>(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<PandocDocxConversionError>);
});
}); });
@@ -404,11 +404,12 @@ describe("动态 reference.docx", () => {
const sourceCodeStyle = stylesXml.match( const sourceCodeStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u /<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0]; )?.[0];
expect(sourceCodeStyle).not.toContain("<w:shd");
expect(sourceCodeStyle).toContain( expect(sourceCodeStyle).toContain(
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>' '<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
); );
expect(sourceCodeStyle).toMatch( expect(sourceCodeStyle).toMatch(
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u /<w:ind\b[^>]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
); );
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"'); expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
expect(stylesXml).toMatch( 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:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
};
expect(createWithBackground()).not.toContain("<w:shd");
expect(createWithBackground("#eef2f6")).toContain(
'<w:shd w:val="clear" w:color="auto" w:fill="EEF2F6"/>'
);
});
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => { it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
const exportConfig: ExportConfig = { const exportConfig: ExportConfig = {
...defaultExportConfig, ...defaultExportConfig,
@@ -506,6 +550,7 @@ describe("动态 reference.docx", () => {
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3); expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3); expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
expect(settingsXml).toContain("<w:evenAndOddHeaders"); expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(settingsXml).toContain("<w:doNotExpandShiftReturn");
expect(stylesXml).toContain('<w:kern w:val="2"'); expect(stylesXml).toContain('<w:kern w:val="2"');
expect(headerXml).toContain("年度 &lt;报告&gt;"); expect(headerXml).toContain("年度 &lt;报告&gt;");
expect(headerXml).toContain("报告 &amp; 计划.md"); expect(headerXml).toContain("报告 &amp; 计划.md");
@@ -89,6 +89,15 @@ describe("DOCX 令牌样式映射", () => {
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => { it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"')) expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
.toBe("code-token-attribute"); .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"')) expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
.toBe("code-token-string"); .toBe("code-token-string");
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":")) expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
@@ -97,6 +106,13 @@ describe("DOCX 令牌样式映射", () => {
.toBe("code-token-title"); .toBe("code-token-title");
expect(resolvePandocSyntaxStyleSlot("NormalTok", " ")) expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
.toBe("code-block-text"); .toBe("code-block-text");
expect(
resolvePandocSyntaxStyleSlot(
"NormalTok",
" method",
{ objectPropertyKey: true }
)
).toBe("code-token-attribute");
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x")) expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
.toBeUndefined(); .toBeUndefined();
}); });
@@ -41,6 +41,9 @@ export function readDocxComputedStyle(
fontStyle: style.fontStyle, fontStyle: style.fontStyle,
color: style.color, color: style.color,
backgroundColor: style.backgroundColor, backgroundColor: style.backgroundColor,
...(style.backgroundImage
? { backgroundImage: style.backgroundImage }
: {}),
lineHeight: style.lineHeight, lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing, letterSpacing: style.letterSpacing,
textAlign: style.textAlign, textAlign: style.textAlign,
@@ -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 { function parseAlpha(value: string | undefined): number | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
@@ -7,6 +7,7 @@ import {
millimetersToPoints, millimetersToPoints,
parseCssBorder, parseCssBorder,
parseCssColor, parseCssColor,
parseCssLinearGradientFallbackColor,
parseCssFontFamilies, parseCssFontFamilies,
parseCssLengthToPt, parseCssLengthToPt,
roundDocxValue roundDocxValue
@@ -292,6 +293,20 @@ function normalizeComputedSlot(
); );
if (background) { if (background) {
style.backgroundColor = 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" const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
? 0 ? 0
+1 -1
View File
@@ -35,7 +35,7 @@ export function createDocxStyleProbeMarkup(): string {
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}></li></ul> <ul ${attribute("unordered-list")}><li ${attribute("list-item")}></li></ul>
<ol ${attribute("ordered-list")}><li></li></ol> <ol ${attribute("ordered-list")}><li></li></ol>
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}></p></blockquote> <blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}></p></blockquote>
<pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>&quot;value&quot;</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span></code></pre> <pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>&quot;value&quot;</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span> <span class="hljs-name" ${attribute("code-token-name")}>section</span></code></pre>
<table ${attribute("table")}> <table ${attribute("table")}>
<thead><tr><th ${attribute("table-header")}></th></tr></thead> <thead><tr><th ${attribute("table-header")}></th></tr></thead>
<tbody><tr><td ${attribute("table-cell")}></td></tr></tbody> <tbody><tr><td ${attribute("table-cell")}></td></tr></tbody>
@@ -55,6 +55,7 @@ const computedProperties = [
"fontStyle", "fontStyle",
"color", "color",
"backgroundColor", "backgroundColor",
"backgroundImage",
"lineHeight", "lineHeight",
"letterSpacing", "letterSpacing",
"textAlign", "textAlign",
+2
View File
@@ -35,6 +35,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
"code-token-operator", "code-token-operator",
"code-token-regexp", "code-token-regexp",
"code-token-punctuation", "code-token-punctuation",
"code-token-name",
"table", "table",
"table-header", "table-header",
"table-cell", "table-cell",
@@ -153,6 +154,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
"code-token-operator": "inline", "code-token-operator": "inline",
"code-token-regexp": "inline", "code-token-regexp": "inline",
"code-token-punctuation": "inline", "code-token-punctuation": "inline",
"code-token-name": "inline",
table: "table", table: "table",
"table-header": "table", "table-header": "table",
"table-cell": "table", "table-cell": "table",
@@ -10,6 +10,7 @@ export const docxComputedStyleSchema = z.object({
fontStyle: cssValueSchema, fontStyle: cssValueSchema,
color: cssValueSchema, color: cssValueSchema,
backgroundColor: cssValueSchema, backgroundColor: cssValueSchema,
backgroundImage: cssValueSchema.optional(),
lineHeight: cssValueSchema, lineHeight: cssValueSchema,
letterSpacing: cssValueSchema, letterSpacing: cssValueSchema,
textAlign: cssValueSchema, textAlign: cssValueSchema,
@@ -4,6 +4,7 @@ import {
millimetersToPoints, millimetersToPoints,
parseCssBorder, parseCssBorder,
parseCssColor, parseCssColor,
parseCssLinearGradientFallbackColor,
parseCssFontFamilies, parseCssFontFamilies,
parseCssLengthToPt, parseCssLengthToPt,
resolveDocxThemeTokens, resolveDocxThemeTokens,
@@ -106,6 +107,12 @@ describe("CSS 到 Word 基础值归一化", () => {
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined(); expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080"); expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
expect(parseCssColor("#00000080")).toBe("#7f7f7f"); 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( expect(
parseCssFontFamilies( parseCssFontFamilies(
'"Source Han Serif SC", "Microsoft YaHei", serif' '"Source Han Serif SC", "Microsoft YaHei", serif'
@@ -185,6 +192,32 @@ describe("DOCX 主题令牌归一化", () => {
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); 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 文档的内容区宽度归一化全宽表格", () => { it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
const tokens = resolveDocxThemeTokens({ const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({ snapshot: createSnapshot({
@@ -3,6 +3,7 @@ const CHUNK_CLASS = "md-code-pagination-chunk";
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position"; const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
const LINE_GROUP_CLASS = "md-code-line-group"; const LINE_GROUP_CLASS = "md-code-line-group";
const LINE_CLASS = "md-code-line"; const LINE_CLASS = "md-code-line";
const LINE_INDENT_CLASS = "md-code-line-indent";
function splitNodeIntoLines(node: Node): Node[][] { function splitNodeIntoLines(node: Node): Node[][] {
const documentRef = node.ownerDocument; const documentRef = node.ownerDocument;
@@ -47,6 +48,56 @@ function lineHasContent(nodes: Node[]) {
return nodes.some((node) => (node.textContent ?? "").length > 0); 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( function createLineGroups(
documentRef: Document, documentRef: Document,
lines: Node[][] lines: Node[][]
@@ -68,7 +119,12 @@ function createLineGroups(
) { ) {
const line = documentRef.createElement("span"); const line = documentRef.createElement("span");
line.className = LINE_CLASS; line.className = LINE_CLASS;
line.append(...(lines[lineIndex] ?? [])); line.append(
...stabilizeLeadingWhitespace(
documentRef,
lines[lineIndex] ?? []
)
);
group.append(line); group.append(line);
} }
+302 -38
View File
@@ -1,13 +1,14 @@
import { import {
DOCX_MEDIA_RASTER_SCALE, DOCX_MEDIA_RASTER_SCALE,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_COUNT,
MAXIMUM_DOCX_MEDIA_PIXELS, MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan, type DocxMediaCapturePlan,
type DocxMediaCaptureTarget, type DocxMediaCaptureTarget,
type DocxMediaAlignment, type DocxMediaAlignment,
type DocxMediaKind, type DocxMediaKind,
type DocxDocumentLayoutPlan, type DocxDocumentLayoutPlan,
type DocxEmojiRunLayout,
type DocxInlineCodeLayout, type DocxInlineCodeLayout,
type DocxListItemLayout, type DocxListItemLayout,
type DocxTableLayout, type DocxTableLayout,
@@ -15,12 +16,19 @@ import {
type PagedDocumentPayload type PagedDocumentPayload
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { PagedDocumentRuntime } from "./paged-document-runtime.js"; import { PagedDocumentRuntime } from "./paged-document-runtime.js";
import { stabilizePagedTableColumns } from "./paged-table-handler.js";
export interface DocxMediaRenderDimensions { export interface DocxMediaRenderDimensions {
contentWidthPx: number; contentWidthPx: number;
contentHeightPx: number; contentHeightPx: number;
} }
export type DocxTableGeometry = Pick<
DocxTableLayout,
"ordinal" | "widthPercent" | "leftOffsetPercent" |
"columnWidthPercents"
>;
declare global { declare global {
interface Window { interface Window {
__mdToPdfRenderDocxMedia?: ( __mdToPdfRenderDocxMedia?: (
@@ -157,6 +165,23 @@ function average(values: readonly number[], fallback: number) {
} }
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) { function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
const stabilizedColumns = Array.from(
table.querySelectorAll<HTMLTableColElement>(
":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 rows = Array.from(table.rows);
const columnCount = rows.reduce( const columnCount = rows.reduce(
(maximum, row) => (maximum, row) =>
@@ -255,7 +280,7 @@ function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
const DOCX_TEXT_BLOCK_SELECTOR = const DOCX_TEXT_BLOCK_SELECTOR =
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " + "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"; ".doc-briefing-meta";
function textNodeCharacters(root: HTMLElement) { function textNodeCharacters(root: HTMLElement) {
@@ -293,9 +318,19 @@ function characterRect(
return rect.width > 0 && rect.height > 0 ? rect : undefined; 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( export function collectDocxTextBlockLayouts(
article: HTMLElement article: HTMLElement
): DocxTextBlockLayout[] { ): DocxTextBlockLayout[] {
const articleRect = article.getBoundingClientRect();
const candidates = Array.from( const candidates = Array.from(
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR) article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
).filter((element) => { ).filter((element) => {
@@ -350,39 +385,33 @@ export function collectDocxTextBlockLayouts(
} }
const text = output.join("").trim(); const text = output.join("").trim();
const computed = getComputedStyle(element); const computed = getComputedStyle(element);
const alert = element.closest<HTMLElement>(".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" const letterSpacingPx = computed.letterSpacing === "normal"
? 0 ? 0
: Number.parseFloat(computed.letterSpacing); : 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 lastLine = lineCharacterRects.at(-1) ?? [];
const sortedLastLine = [...lastLine].sort( const distributed = hasDistributedLastLine(
(left, right) => left.left - right.left 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 const lineTops = lineCharacterRects
.map((rects) => rects.length > 0 .map((rects) => rects.length > 0
? Math.min(...rects.map((rect) => rect.top)) ? Math.min(...rects.map((rect) => rect.top))
@@ -401,6 +430,19 @@ export function collectDocxTextBlockLayouts(
letterSpacingPt: Number.isFinite(letterSpacingPx) letterSpacingPt: Number.isFinite(letterSpacingPx)
? letterSpacingPx * 0.75 ? letterSpacingPx * 0.75
: 0, : 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 alignment: distributed
? "distribute" as const ? "distribute" as const
: cellAlignment(computed.textAlign), : cellAlignment(computed.textAlign),
@@ -447,6 +489,18 @@ export function collectDocxListItemLayouts(
if (!rect) { if (!rect) {
return []; 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; let listDepth = -1;
for ( for (
let ancestor: Element | null = item.parentElement; let ancestor: Element | null = item.parentElement;
@@ -464,7 +518,8 @@ export function collectDocxListItemLayouts(
textStartPt: Math.max( textStartPt: Math.max(
0, 0,
(rect.left - articleLeft) * 0.75 (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; 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<string, number>();
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( export function collectDocxInlineCodeLayouts(
article: HTMLElement article: HTMLElement
): DocxInlineCodeLayout[] { ): DocxInlineCodeLayout[] {
@@ -545,7 +680,7 @@ export function collectDocxDocumentLayoutPlan(
); );
return { return {
ordinal: index + 1, ordinal: index + 1,
widthPercent: Math.min(100, (width / contentWidth) * 100), widthPercent: Math.min(300, (width / contentWidth) * 100),
leftOffsetPercent: Math.max( leftOffsetPercent: Math.max(
0, 0,
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100) Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
@@ -570,11 +705,112 @@ export function collectDocxDocumentLayoutPlan(
})) }))
}; };
}); });
const emojiRuns = collectDocxEmojiRunLayouts(article);
return { return {
tables, tables,
textBlocks: collectDocxTextBlockLayouts(article), textBlocks: collectDocxTextBlockLayouts(article),
listItems: collectDocxListItemLayouts(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<string, HTMLTableElement[]>();
let anonymousTableIndex = 0;
for (const table of root.querySelectorAll<HTMLTableElement>(
".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<HTMLElement>(
".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<HTMLElement>(
".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(",") ].join(",")
) )
); );
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) { if (candidates.length > MAXIMUM_DOCX_MEDIA_COUNT) {
throw new Error( throw new Error(
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}` `DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_MEDIA_COUNT}`
); );
} }
@@ -658,6 +894,20 @@ export async function renderDocxMediaCapturePlan(
payload: PagedDocumentPayload, payload: PagedDocumentPayload,
dimensions: DocxMediaRenderDimensions dimensions: DocxMediaRenderDimensions
): Promise<DocxMediaCapturePlan> { ): Promise<DocxMediaCapturePlan> {
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, { const renderResult = await runtime.renderContinuous(payload, {
geometryCss: createGeometryCss(dimensions), geometryCss: createGeometryCss(dimensions),
themeMedia: "print" themeMedia: "print"
@@ -665,10 +915,24 @@ export async function renderDocxMediaCapturePlan(
if (!renderResult) { if (!renderResult) {
throw new Error("DOCX 媒体渲染已取消"); throw new Error("DOCX 媒体渲染已取消");
} }
stabilizePagedTableColumns(root, dimensions.contentHeightPx);
const continuousLayout = collectDocxDocumentLayoutPlan(
root,
dimensions
);
return { return {
targets: collectDocxMediaCaptureTargets(root, dimensions), targets: collectDocxMediaCaptureTargets(root, dimensions),
echartsErrors: renderResult.echartsErrors, echartsErrors: Array.from(new Set([
mermaidErrors: renderResult.mermaidErrors, ...pagedResult.echartsErrors,
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions) ...renderResult.echartsErrors
])),
mermaidErrors: Array.from(new Set([
...pagedResult.mermaidErrors,
...renderResult.mermaidErrors
])),
documentLayout: mergePagedTableGeometries(
continuousLayout,
pagedTableGeometries
)
}; };
} }
+1
View File
@@ -17,5 +17,6 @@ export * from "./paged-page-sequence.js";
export * from "./semantic-cover-fit.js"; export * from "./semantic-cover-fit.js";
export * from "./paged-page-decorations.js"; export * from "./paged-page-decorations.js";
export * from "./paged-render-target.js"; export * from "./paged-render-target.js";
export * from "./paged-table-handler.js";
export * from "./pdf-document-links.js"; export * from "./pdf-document-links.js";
export * from "./preview-styles.js"; export * from "./preview-styles.js";
@@ -37,7 +37,11 @@ import { renderMermaidDefinitions } from "./mermaid-renderer.js";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js"; import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
import type { MermaidOutputMode } from "./mermaid-static-image.js"; import type { MermaidOutputMode } from "./mermaid-static-image.js";
import { enablePrintMediaForPreview } from "./preview-styles.js"; import { enablePrintMediaForPreview } from "./preview-styles.js";
import "./paged-table-handler.js"; import {
forcePagedTableRowsToNextPage,
missingPagedTableRowIds,
stabilizePagedTableColumns
} from "./paged-table-handler.js";
import { import {
buildPagedMediaCss, buildPagedMediaCss,
continuousDocumentGeometryCss, continuousDocumentGeometryCss,
@@ -61,6 +65,7 @@ export interface PagedDocumentRenderOptions {
target: PagedRenderTarget; target: PagedRenderTarget;
shouldContinue?: () => boolean; shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode; mermaidOutput?: MermaidOutputMode;
allowIncompleteTableGeometry?: boolean;
} }
export interface ContinuousDocumentRenderOptions { export interface ContinuousDocumentRenderOptions {
@@ -75,6 +80,33 @@ export interface PreviewEngineStyles {
echartsCss: string; 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<HTMLElement>(
"#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; export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
function waitForImage( function waitForImage(
@@ -699,6 +731,7 @@ export class PagedDocumentRuntime {
payload.features.includes("echarts") || payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") || content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image") || content.querySelector("img.md-document-image") ||
content.querySelector("table") ||
content.querySelector('[data-semantic-region="cover"]') content.querySelector('[data-semantic-region="cover"]')
) { ) {
const measurement = mountMeasurementContainer( const measurement = mountMeasurementContainer(
@@ -710,6 +743,14 @@ export class PagedDocumentRuntime {
); );
try { try {
await documentRef.fonts.ready; await documentRef.fonts.ready;
protectFittableInlineCodeParagraphs(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
stabilizePagedTableColumns(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
constrainSemanticCoversToPage( constrainSemanticCoversToPage(
measurement.host, measurement.host,
getPageContentDimensions(payload.exportConfig).height getPageContentDimensions(payload.exportConfig).height
@@ -816,6 +857,51 @@ export class PagedDocumentRuntime {
stylesheets, stylesheets,
this.root 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( const mediaIds = getMediaBackfillIdsInDocumentOrder(
repaginationSource repaginationSource
); );
@@ -127,9 +127,21 @@ svg {
table-layout: auto; table-layout: auto;
} }
#write table[data-stabilized-columns="true"] {
table-layout: fixed !important;
}
#write th, #write th,
#write td { #write td {
overflow-wrap: anywhere; 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 { .mermaid {
@@ -421,6 +433,15 @@ ${buildFooterCss(config)}
widows: 3; widows: 3;
} }
#write > p.md-inline-code-paragraph {
orphans: 1;
widows: 1;
}
#write li {
text-align-last: left;
}
#write h1, #write h1,
#write h2, #write h2,
#write h3, #write h3,
@@ -489,6 +510,11 @@ ${buildFooterCss(config)}
word-break: inherit; word-break: inherit;
} }
#write .md-code-line-indent {
display: inline-block;
white-space: pre;
}
#write table[data-empty-split-table="true"] { #write table[data-empty-split-table="true"] {
display: none; display: none;
} }
@@ -9,8 +9,352 @@ interface PagedChunker {
source: ParentNode; source: ParentNode;
} }
interface PagedHandlerContext { function measuredColumnWidths(table: HTMLTableElement) {
chunker: PagedChunker; 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<HTMLTableElement>("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<HTMLElement>(".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<HTMLElement>(
"[data-mdtp-table-row-id]"
)).filter(isVisiblePagedRow)
.map((row) => row.dataset.mdtpTableRowId)
.filter((id): id is string => Boolean(id))
);
return Array.from(source.querySelectorAll<HTMLTableRowElement>(
'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<HTMLTableElement>("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<HTMLTableRowElement>(
"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<HTMLTableColElement>(
":scope > colgroup"
)
);
if (sourceGroups.length === 0) {
return;
}
const renderedGroups = Array.from(
renderedTable.querySelectorAll<HTMLTableColElement>(
":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( function elementAncestors(
@@ -48,6 +392,21 @@ class RepeatTableHeadersHandler extends Handler {
breakToken?: PagedBreakToken breakToken?: PagedBreakToken
) { ) {
this.splitTableRefs = []; this.splitTableRefs = [];
for (const renderedTable of pageElement.querySelectorAll<HTMLTableElement>(
"table[data-ref]"
)) {
const ref = renderedTable.getAttribute("data-ref");
if (!ref) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (sourceTable) {
restorePagedTableColumns(sourceTable, renderedTable);
}
}
const element = resolveBreakTokenElement(breakToken?.node); const element = resolveBreakTokenElement(breakToken?.node);
if (!element) { if (!element) {
return; return;
@@ -81,6 +440,21 @@ class RepeatTableHeadersHandler extends Handler {
} }
layout(rendered: HTMLElement) { layout(rendered: HTMLElement) {
for (const renderedTable of rendered.querySelectorAll<HTMLTableElement>(
"table[data-ref]"
)) {
const ref = renderedTable.getAttribute("data-ref");
if (!ref) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (sourceTable) {
restorePagedTableColumns(sourceTable, renderedTable);
}
}
for (const ref of this.splitTableRefs) { for (const ref of this.splitTableRefs) {
const renderedTable = const renderedTable =
rendered.querySelector<HTMLTableElement>( rendered.querySelector<HTMLTableElement>(
@@ -101,23 +475,7 @@ class RepeatTableHeadersHandler extends Handler {
continue; continue;
} }
const firstChild = renderedTable.firstChild; restorePagedTableStructure(sourceTable, renderedTable);
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
);
}
}
renderedTable.setAttribute("data-repeated-header", "true"); renderedTable.setAttribute("data-repeated-header", "true");
} }
@@ -98,6 +98,32 @@ describe("代码块分页预处理", () => {
).toBe("return"); ).toBe("return");
}); });
it("将高亮代码行首缩进封装为分页稳定节点", () => {
const root = createRoot(
'<span class="hljs-punctuation">{</span>\n ' +
'<span class="hljs-attr">"nested"</span>: true\n' +
'<span class="hljs-punctuation">}</span>\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("重复调用不会再次包装已经准备的代码块", () => { it("重复调用不会再次包装已经准备的代码块", () => {
const root = createRoot("line-1\nline-2\n"); const root = createRoot("line-1\nline-2\n");
@@ -2,13 +2,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
collectPagedDocxTableGeometries,
collectDocxDocumentLayoutPlan, collectDocxDocumentLayoutPlan,
collectDocxInlineCodeLayouts, collectDocxInlineCodeLayouts,
collectDocxListItemLayouts, collectDocxListItemLayouts,
collectDocxTextBlockLayouts, collectDocxTextBlockLayouts,
collectDocxMediaCaptureTargets, collectDocxMediaCaptureTargets,
forcePagedTableRowsToNextPage,
mergePagedTableGeometries,
missingPagedTableRowIds,
protectFittableInlineCodeParagraphs,
removeInheritedPagedSplitJustification, removeInheritedPagedSplitJustification,
renderDocxMediaCapturePlan renderDocxMediaCapturePlan,
restorePagedTableColumns,
restorePagedTableStructure,
stabilizePagedTableColumns
} from "../src/index.js"; } from "../src/index.js";
describe("DOCX 媒体捕获计划", () => { describe("DOCX 媒体捕获计划", () => {
@@ -18,6 +26,10 @@ describe("DOCX 媒体捕获计划", () => {
}); });
it("连续布局使用原始打印媒体主题 CSS", async () => { it("连续布局使用原始打印媒体主题 CSS", async () => {
const render = vi.fn(async () => ({
echartsErrors: [],
mermaidErrors: []
}));
const renderContinuous = vi.fn(async () => ({ const renderContinuous = vi.fn(async () => ({
echartsErrors: [], echartsErrors: [],
mermaidErrors: [] mermaidErrors: []
@@ -26,7 +38,7 @@ describe("DOCX 媒体捕获计划", () => {
root.innerHTML = '<article id="write"></article>'; root.innerHTML = '<article id="write"></article>';
await renderDocxMediaCapturePlan( await renderDocxMediaCapturePlan(
{ renderContinuous } as never, { render, renderContinuous } as never,
root, root,
{ {
articleHtml: '<article id="write"></article>', articleHtml: '<article id="write"></article>',
@@ -57,6 +69,155 @@ describe("DOCX 媒体捕获计划", () => {
expect.anything(), expect.anything(),
expect.objectContaining({ themeMedia: "print" }) expect.objectContaining({ themeMedia: "print" })
); );
expect(render).toHaveBeenCalledWith(
expect.anything(),
{
target: "pdf",
allowIncompleteTableGeometry: true
}
);
});
it("仅将能完整放入一页的顶层行内代码段落保持为整体", () => {
document.body.innerHTML = `
<article id="write">
<p id="short" class="md-inline-code-paragraph"></p>
<p id="long" class="md-inline-code-paragraph"></p>
<table><tbody><tr><td>
<p id="cell" class="md-inline-code-paragraph"></p>
</td></tr></tbody></table>
</article>
`;
const short = document.querySelector<HTMLElement>("#short")!;
const long = document.querySelector<HTMLElement>("#long")!;
const cell = document.querySelector<HTMLElement>("#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 = `
<main class="pagedjs_pages">
<section class="pagedjs_page">
<div class="pagedjs_area">
<table data-ref="table-a">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120">
<col data-stabilized-width-px="280">
</colgroup>
<tbody><tr><td>A</td><td>B</td></tr></tbody>
</table>
</div>
</section>
<section class="pagedjs_page">
<div class="pagedjs_area">
<table data-ref="table-a">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120">
<col data-stabilized-width-px="280">
</colgroup>
<tbody><tr><td>C</td><td>D</td></tr></tbody>
</table>
</div>
</section>
</main>
`;
const areas = Array.from(
document.querySelectorAll<HTMLElement>(".pagedjs_area")
);
const tables = Array.from(
document.querySelectorAll<HTMLTableElement>("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 末行两端对齐继承给子块的容器标记", () => { it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
@@ -334,6 +495,382 @@ describe("DOCX 媒体捕获计划", () => {
}); });
}); });
it("保留主题表格超出文档内容盒的实测宽度", () => {
document.body.innerHTML = `
<article id="write"><table><tbody><tr><td></td></tr></tbody></table></article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("table")!;
const cell = document.querySelector<HTMLTableCellElement>("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 = `
<article id="write"><table><tbody><tr><td>A</td><td>B</td></tr></tbody></table></article>
`;
const table = document.querySelector<HTMLTableElement>("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 = `
<article id="write">
<table data-stabilized-columns="true">
<colgroup data-stabilized-columns="true">
<col data-stabilized-width-px="120" style="width: 140px">
<col data-stabilized-width-px="280" style="width: 260px">
</colgroup>
<tbody><tr><td>A</td><td>B</td></tr></tbody>
</table>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("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 = `
<table><tbody>
<tr><td colspan="2"></td></tr>
<tr><td></td><td></td></tr>
</tbody></table>
`;
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 = `
<table id="source">
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
<thead><tr><th>A</th><th>B</th></tr></thead>
<tbody><tr><td></td><td></td></tr></tbody>
</table>
<table id="rendered">
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
<tbody><tr><td></td><td></td></tr></tbody>
</table>
`;
const source = document.querySelector<HTMLTableElement>("#source")!;
const rendered = document.querySelector<HTMLTableElement>("#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 = `
<table><tbody>
<tr id="normal"><td></td><td></td></tr>
<tr id="tall"><td></td><td></td></tr>
</tbody></table>
`;
const table = document.querySelector<HTMLTableElement>("table")!;
const normal = document.querySelector<HTMLTableRowElement>("#normal")!;
const tall = document.querySelector<HTMLTableRowElement>("#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 = `
<main id="source"><table><tbody>
<tr data-mdtp-table-row-id="row-1" data-mdtp-table-row-keep-whole="true"><td></td></tr>
<tr data-mdtp-table-row-id="row-2" data-mdtp-table-row-keep-whole="true"><td></td></tr>
<tr data-mdtp-table-row-id="row-tall" data-mdtp-table-row-keep-whole="false"><td></td></tr>
</tbody></table></main>
<main id="rendered"><table><tbody>
<tr data-mdtp-table-row-id="row-2"><td></td></tr>
</tbody></table></main>
`;
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<HTMLTableRowElement>(
'[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 = `
<main id="source"><table id="source-table">
<colgroup><col><col></colgroup>
<thead><tr><th></th><th></th></tr></thead>
<tbody>
<tr data-mdtp-table-row-id="row-1"><td></td><td></td></tr>
<tr data-mdtp-table-row-id="row-2"><td></td><td></td></tr>
<tr data-mdtp-table-row-id="row-3"><td></td><td></td></tr>
</tbody>
<tfoot><tr><td></td><td></td></tr></tfoot>
</table></main>
`;
const source = document.querySelector("#source")!;
expect(forcePagedTableRowsToNextPage(source, ["row-2"])).toBe(1);
const tables = source.querySelectorAll<HTMLTableElement>("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 = `
<main id="source"><table><tbody>
<tr data-mdtp-table-row-id="row-clipped" data-mdtp-table-row-keep-whole="true"><td></td></tr>
<tr data-mdtp-table-row-id="row-visible" data-mdtp-table-row-keep-whole="true"><td></td></tr>
</tbody></table></main>
<main id="rendered">
<section class="pagedjs_area" id="page-1">
<table><tbody>
<tr id="clipped" data-mdtp-table-row-id="row-clipped"><td></td></tr>
<tr id="visible" data-mdtp-table-row-id="row-visible"><td></td></tr>
</tbody></table>
</section>
<section class="pagedjs_area" id="page-2">
<table><tbody>
<tr id="visible-duplicate" data-mdtp-table-row-id="row-visible"><td></td></tr>
</tbody></table>
</section>
</main>
`;
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<HTMLElement>("#page-1")!.getBoundingClientRect =
() => rect(0, 0, 600, 800);
document.querySelector<HTMLElement>("#page-2")!.getBoundingClientRect =
() => rect(0, 900, 600, 1700);
document.querySelector<HTMLElement>("#clipped")!.getBoundingClientRect =
() => rect(0, 810, 600, 850);
document.querySelector<HTMLElement>("#visible")!.getBoundingClientRect =
() => rect(0, 780, 600, 820);
document.querySelector<HTMLElement>("#visible-duplicate")!
.getBoundingClientRect = () => rect(0, 920, 600, 960);
expect(missingPagedTableRowIds(
document.querySelector("#source")!,
document.querySelector("#rendered")!
)).toEqual(["row-clipped"]);
});
it("分页片段同步源表冻结列宽而不重复 colgroup", () => {
document.body.innerHTML = `
<table id="source" data-stabilized-columns="true">
<colgroup data-stabilized-columns="true"><col data-stabilized-width-px="118" style="width: 120px"><col data-stabilized-width-px="282" style="width: 280px"></colgroup>
<tbody><tr><td></td><td></td></tr></tbody>
</table>
<table id="rendered">
<colgroup><col style="width: 100px"><col style="width: 300px"></colgroup>
<tbody><tr><td></td><td></td></tr></tbody>
</table>
`;
const source = document.querySelector<HTMLTableElement>("#source")!;
const rendered = document.querySelector<HTMLTableElement>("#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("按真实上下文采集行内代码字号、颜色和盒模型", () => { it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
document.body.innerHTML = ` document.body.innerHTML = `
<article id="write" style="font-size: 20px"> <article id="write" style="font-size: 20px">
@@ -419,6 +956,42 @@ describe("DOCX 媒体捕获计划", () => {
} }
}); });
it("不把普通两端对齐列表的自然末行误判为分散对齐", () => {
document.body.innerHTML = `
<article id="write">
<ul><li style="text-align: justify; text-align-last: left"></li></ul>
</article>
`;
const article = document.querySelector<HTMLElement>("#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 中的实际行断点", () => { it("采集正文文本块在 Chromium 中的实际行断点", () => {
document.body.innerHTML = ` document.body.innerHTML = `
<article id="write"> <article id="write">
@@ -40,6 +40,14 @@ const payload: PagedPreviewPayload = {
}; };
describe("分页预览协议", () => { 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", () => { it("生成真实纸张尺寸和页边距 CSS", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload); const css = buildPagedMediaCss(defaultExportConfig, payload);
@@ -278,6 +286,12 @@ describe("分页预览协议", () => {
expect(documentBaseCss).toContain("white-space: pre-wrap;"); expect(documentBaseCss).toContain("white-space: pre-wrap;");
expect(documentBaseCss).toContain("overflow-wrap: anywhere;"); expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
expect(documentBaseCss).toContain("word-break: break-word;"); 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); const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-line"); expect(css).toContain("#write .md-code-line");
+108
View File
@@ -23,6 +23,7 @@ import hljs from "highlight.js";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import type { Options as MarkdownItOptions } from "markdown-it"; import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs"; 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 type Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js"; import { renderDocumentStructure } from "./render-document-structure.js";
@@ -49,6 +50,7 @@ export interface RenderMarkdownOptions {
export interface RenderedMarkdown export interface RenderedMarkdown
extends Omit<RenderedMarkdownDocument, "rendererVersion"> { extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
rendererVersion: typeof RENDERER_VERSION; rendererVersion: typeof RENDERER_VERSION;
markdownBody: string;
} }
const markdownOptions: MarkdownItOptions = { const markdownOptions: MarkdownItOptions = {
@@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions)
trust: false trust: false
}); });
const tableBreakMarkupPattern = /^(?:<br>|<br\/>|<br \/>)/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 = () => "<br>";
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 `<span class="md-alert-text md-alert-text-${escapeAttribute(alertType)}">${markdown.utils.escapeHtml(token?.content ?? "")}</span>`;
};
markdown.renderer.rules.markdown_alert_body_break = () => "</p>\n<p>";
const defaultValidateLink = markdown.validateLink.bind(markdown); const defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) => markdown.validateLink = (href) =>
defaultValidateLink(href) || defaultValidateLink(href) ||
@@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = (
if (standaloneImage) { if (standaloneImage) {
return '<figure class="md-document-image-block">\n'; return '<figure class="md-document-image-block">\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 return defaultParagraphOpenRenderer
? defaultParagraphOpenRenderer( ? defaultParagraphOpenRenderer(
tokens, tokens,
@@ -367,6 +474,7 @@ export function renderMarkdown(
return { return {
rendererVersion: RENDERER_VERSION, rendererVersion: RENDERER_VERSION,
markdownBody: parsed.content,
articleHtml, articleHtml,
bodyHtml, bodyHtml,
metadata, metadata,
@@ -6,6 +6,38 @@ import {
} from "../src/render-markdown.js"; } from "../src/render-markdown.js";
describe("renderMarkdown", () => { describe("renderMarkdown", () => {
it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => {
const result = renderMarkdown(
"> [!CAUTION]\n> **关键约束**:必须执行。",
);
expect(result.bodyHtml).toContain(
'<blockquote class="md-alert md-alert-caution">',
);
expect(result.bodyHtml).toContain(
'<p><span class="md-alert-text md-alert-text-caution">Caution</span></p>\n<p><strong>关键约束</strong>:必须执行。</p>',
);
expect(result.bodyHtml).not.toContain("[!CAUTION]");
});
it("为含行内代码的普通段落标记分页保护类", () => {
const result = renderMarkdown("段落前 `inline_code` 段落后");
expect(result.articleHtml).toContain(
'<p class="md-inline-code-paragraph">段落前 <code>inline_code</code> 段落后</p>',
);
});
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 扩展并识别能力", () => { it("渲染常用 Markdown 扩展并识别能力", () => {
const result = renderMarkdown(` const result = renderMarkdown(`
# #
@@ -80,6 +112,39 @@ const answer = 42;
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>'); expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
}); });
it("只在 GFM 表格单元格内解释无属性 br 换行", () => {
const result = renderMarkdown(`
| | |
| --- | --- |
| | <br><BR/><br /> |
<br><br/> <br />
| | |
| --- | --- |
| | \\<br> &lt;br&gt; |
| | \`<br>\` |
| | <br class="unsafe"> |
\`\`\`html
<br>
\`\`\`
`);
expect(result.bodyHtml).toContain(
"第一行<br />第二行<br />第三行<br />第四行"
);
expect(result.bodyHtml).toContain(
"正文中的 &lt;br&gt;、&lt;br/&gt; 和 &lt;br /&gt; 保持文本"
);
expect(result.bodyHtml).toContain("&lt;br&gt; 与 &lt;br&gt;");
expect(result.bodyHtml).toContain("<code>&lt;br&gt;</code>");
expect(result.bodyHtml).toContain("&lt;br class=\"unsafe\"&gt;");
expect(result.bodyHtml).toContain(
'<pre class="md-fences" lang="html"><code class="language-html">'
);
});
it("读取并规范化 Front Matter 元数据", () => { it("读取并规范化 Front Matter 元数据", () => {
const result = renderMarkdown(`--- const result = renderMarkdown(`---
title: 项目报告 title: 项目报告
+1 -1
View File
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json"); const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
const bundle = JSON.parse(await readFile(bundlePath, "utf8")); const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
const outputRoot = join(repositoryRoot, "output", "font-packs", "root"); 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 ( if (
bundle.schemaVersion !== 1 || bundle.schemaVersion !== 1 ||
@@ -71,6 +71,29 @@ function hasIndependentCover(manifest) {
return profiles.includes("project-report") || profiles.includes("tender"); 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) { export function readBundledThemeMatrixDefinitions(themesDirectory) {
assert( assert(
typeof themesDirectory === "string" && themesDirectory.length > 0, typeof themesDirectory === "string" && themesDirectory.length > 0,
@@ -8,7 +8,9 @@ import {
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS, DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS,
DOCX_LAYOUT_MATRIX_ORIENTATIONS, DOCX_LAYOUT_MATRIX_ORIENTATIONS,
getDocxFontGateFailures, getDocxFontGateFailures,
isCorpusTextColorEquivalent,
readBundledThemeMatrixDefinitions, readBundledThemeMatrixDefinitions,
resolveEffectiveCoverPageCount,
summarizeDocxLayoutVisualMatrix summarizeDocxLayoutVisualMatrix
} from "./docx-layout-visual-matrix-cases.mjs"; } 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);
});
+100
View File
@@ -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
};
}
+60
View File
@@ -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
}
);
});
+232
View File
@@ -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
};
}
+135
View File
@@ -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 });
}
});
+447 -14
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom"; import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
import matter from "gray-matter";
import { import {
createPdfDocumentSnapshot, createPdfDocumentSnapshot,
@@ -11,6 +12,8 @@ import {
createWordPdfAdapter, createWordPdfAdapter,
createWpsPdfAdapter, createWpsPdfAdapter,
getBlockingVisualDiffIssues, getBlockingVisualDiffIssues,
isCrossEngineRasterEquivalent,
normalizePdfEditableText,
renderPdfVisualDiffHtml, renderPdfVisualDiffHtml,
serializePdfVisualDiffJson serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js"; } from "../packages/document-visual-diff/dist/index.js";
@@ -21,9 +24,14 @@ import {
import { import {
createDocxLayoutVisualMatrixCases, createDocxLayoutVisualMatrixCases,
getDocxFontGateFailures, getDocxFontGateFailures,
isCorpusTextColorEquivalent,
readBundledThemeMatrixDefinitions, readBundledThemeMatrixDefinitions,
resolveEffectiveCoverPageCount,
summarizeDocxLayoutVisualMatrix summarizeDocxLayoutVisualMatrix
} from "./docx-layout-visual-matrix-cases.mjs"; } from "./docx-layout-visual-matrix-cases.mjs";
import {
DOCX_REAL_WORLD_CORPUS
} from "./docx-real-world-corpus.mjs";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const WORD_NAMESPACE = const WORD_NAMESPACE =
@@ -32,10 +40,10 @@ const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math"; "http://schemas.openxmlformats.org/officeDocument/2006/math";
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, ".."); const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join( const outputDirectory = path.resolve(
repositoryDirectory, repositoryDirectory,
"output", process.env.MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR?.trim() ||
"docx-layout-visual-matrix" path.join("output", "docx-layout-visual-matrix")
); );
const matrixScript = path.join( const matrixScript = path.join(
repositoryDirectory, repositoryDirectory,
@@ -64,6 +72,23 @@ const selectedOrientation =
process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined; process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
const selectedMarginScenarioId = const selectedMarginScenarioId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined; 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 const scopedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId) ? cases.filter((entry) => entry.id === selectedCaseId)
: selectedScope === "cover" : selectedScope === "cover"
@@ -185,6 +210,43 @@ function paragraphBlockKind(paragraph, styleId) {
return "paragraph"; 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) { function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath))); const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"]; const documentXml = entries["word/document.xml"];
@@ -193,13 +255,83 @@ function extractEditableContract(docxPath) {
decoder.decode(documentXml), decoder.decode(documentXml),
"application/xml" "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 section = "cover";
let hasCoverSection = false; let hasCoverSection = false;
const paragraphs = []; const paragraphs = [];
let inlineCodeParagraphCount = 0;
let inlineCodeExactLineRuleCount = 0;
let pureMathTableParagraphCount = 0;
let stabilizedPureMathTableParagraphCount = 0;
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter( for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE (node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) { )) {
const descendants = Array.from(paragraph.getElementsByTagName("*")); 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( const hasSectionBreak = descendants.some(
(node) => (node) =>
node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
@@ -209,20 +341,53 @@ function extractEditableContract(docxPath) {
section = "body"; section = "body";
continue; continue;
} }
const text = descendants.flatMap((node) => const hardBreakSegments = [""];
const mathCharacterIndexes = [];
let normalizedCharacterOffset = 0;
for (const node of descendants) {
if (
node.localName === "t" && node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || (node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE) node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""] ) {
: [] const nodeText = (node.textContent ?? "").replace(
).join(""); /[\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)) { if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
continue; continue;
} }
const styleId = paragraphStyleId(paragraph); const styleId = paragraphStyleId(paragraph);
const tableCoordinates = paragraphTableCoordinates(paragraph, tableIndexes);
paragraphs.push({ paragraphs.push({
index: paragraphs.length, index: paragraphs.length,
text, text,
...(mathCharacterIndexes.length > 0
? { mathCharacterIndexes }
: {}),
...(hardBreakSegments.length > 1
? { hardBreakSegments }
: {}),
...(inlineCode ? { hasInlineCode: true } : {}),
...(tableCoordinates ?? {}),
...(styleId ? { styleId } : {}), ...(styleId ? { styleId } : {}),
role: paragraphRole(styleId), role: paragraphRole(styleId),
blockKind: paragraphBlockKind(paragraph, styleId), blockKind: paragraphBlockKind(paragraph, styleId),
@@ -236,7 +401,13 @@ function extractEditableContract(docxPath) {
} }
return { return {
text: paragraphs.map((paragraph) => paragraph.text).join(""), 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) { function buildPageSemantics(pageCount, coverPageCount, config) {
assert( assert(
pageCount > coverPageCount, pageCount > coverPageCount,
@@ -423,6 +670,108 @@ function reportCoverGateFailures(report, label) {
.map((issue) => `${label}: ${issue.code} - ${issue.message}`); .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) { function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap( const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues (paragraph) => paragraph.issues
@@ -599,9 +948,18 @@ assert(
`视觉矩阵用例不存在:${selectedCaseId}` `视觉矩阵用例不存在:${selectedCaseId}`
); );
assert( assert(
selectedScope === "full" || selectedScope === "cover", ["full", "cover", "corpus"].includes(selectedScope),
`不支持的视觉矩阵作用域:${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( assert(
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation), !selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
`不支持的视觉矩阵方向:${selectedOrientation}` `不支持的视觉矩阵方向:${selectedOrientation}`
@@ -632,6 +990,11 @@ for (const caseDefinition of selectedCases) {
); );
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile); const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const editable = extractEditableContract(docxPath); const editable = extractEditableContract(docxPath);
const effectiveCoverPageCount = resolveEffectiveCoverPageCount({
caseDefinition,
scope: selectedScope,
sourceMetadata: corpusSourceMetadata
});
const wordGeneration = await wordAdapter.generate({ docxPath }); const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath }); const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf"); const wordPdfPath = path.join(caseDirectory, "word.pdf");
@@ -668,17 +1031,17 @@ for (const caseDefinition of selectedCases) {
}); });
const chromiumPageSemantics = buildPageSemantics( const chromiumPageSemantics = buildPageSemantics(
chromium.pageCount, chromium.pageCount,
caseDefinition.coverPageCount, effectiveCoverPageCount,
result.exportConfig result.exportConfig
); );
const wordPageSemantics = buildPageSemantics( const wordPageSemantics = buildPageSemantics(
word.pageCount, word.pageCount,
caseDefinition.coverPageCount, effectiveCoverPageCount,
result.exportConfig result.exportConfig
); );
const wpsPageSemantics = buildPageSemantics( const wpsPageSemantics = buildPageSemantics(
wps.pageCount, wps.pageCount,
caseDefinition.coverPageCount, effectiveCoverPageCount,
result.exportConfig result.exportConfig
); );
const reportOptions = { const reportOptions = {
@@ -715,6 +1078,23 @@ for (const caseDefinition of selectedCases) {
word: inspectInlineCodeContinuity(word, "Microsoft Word"), word: inspectInlineCodeContinuity(word, "Microsoft Word"),
wps: inspectInlineCodeContinuity(wps, "WPS Writer") 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"); const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true }); fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -742,9 +1122,13 @@ for (const caseDefinition of selectedCases) {
renderedFonts: wps.fonts, renderedFonts: wps.fonts,
engine: "wps" engine: "wps"
}), }),
...(expectInlineCodeProbes
? [
...inlineCodeContinuity.chromium.failures, ...inlineCodeContinuity.chromium.failures,
...inlineCodeContinuity.word.failures, ...inlineCodeContinuity.word.failures,
...inlineCodeContinuity.wps.failures, ...inlineCodeContinuity.wps.failures
]
: []),
...reportGateFailures(wordReport, "Chromium/Word"), ...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"), ...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS") ...reportGateFailures(officeReport, "Word/WPS")
@@ -755,6 +1139,45 @@ for (const caseDefinition of selectedCases) {
...reportCoverGateFailures(wpsReport, "Chromium/WPS"), ...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
...reportCoverGateFailures(officeReport, "Word/WPS") ...reportCoverGateFailures(officeReport, "Word/WPS")
])] ])]
: 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; : allCurrentFailures;
gateFailures.push( gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`) ...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
@@ -772,7 +1195,7 @@ for (const caseDefinition of selectedCases) {
themeDefaultMargins: caseDefinition.themeDefaultMargins, themeDefaultMargins: caseDefinition.themeDefaultMargins,
paper: caseDefinition.paper, paper: caseDefinition.paper,
exportConfig: result.exportConfig, exportConfig: result.exportConfig,
coverPageCount: caseDefinition.coverPageCount, coverPageCount: effectiveCoverPageCount,
pages: { pages: {
chromium: chromium.pageCount, chromium: chromium.pageCount,
word: word.pageCount, word: word.pageCount,
@@ -787,7 +1210,9 @@ for (const caseDefinition of selectedCases) {
wps: wps.fonts wps: wps.fonts
}, },
editableParagraphCount: editable.paragraphs.length, editableParagraphCount: editable.paragraphs.length,
translationInvariants: editable.translationInvariants,
inlineCodeContinuity, inlineCodeContinuity,
codeIndentation,
reports: { reports: {
word: reportSummary(wordReport), word: reportSummary(wordReport),
wps: reportSummary(wpsReport), wps: reportSummary(wpsReport),
@@ -811,6 +1236,14 @@ const summary = {
matrixDefinition, matrixDefinition,
selectedCaseId, selectedCaseId,
selectedScope, selectedScope,
corpus: corpusDefinition
? {
id: corpusDefinition.id,
label: corpusDefinition.label,
markdownPath: corpusDefinition.markdownPath,
requiredFeatures: corpusDefinition.requiredFeatures
}
: undefined,
selectedOrientation, selectedOrientation,
selectedMarginScenarioId, selectedMarginScenarioId,
execution: { execution: {
+4
View File
@@ -0,0 +1,4 @@
process.stderr.write(
"verify-docx-release-gate-560 已改为兼容入口:每次只执行下一套独立 140,不再启动整批 560。\n"
);
await import("./verify-docx-release-gate-suite.mjs");
+277
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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) =>
`<tr><td>${escapeHtml(entry.caseId)}</td><td class="${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</td><td>${escapeHtml(entry.finishedAt || "")}</td><td>${entry.summaryPath ? `<a href="${escapeHtml(entry.summaryPath)}">JSON</a>` : "-"}</td><td>${escapeHtml(entry.error || entry.gateFailures?.join("") || "")}</td></tr>`
).join("");
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(aggregate.suite.label)}</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d0d5dd;padding:8px;text-align:left;vertical-align:top}.passed{background:#e6f7ec}.gate-failed,.error{background:#ffe4e4}</style></head><body><h1>${escapeHtml(aggregate.suite.label)}</h1><p>${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。</p><table><thead><tr><th>场景</th><th>状态</th><th>完成时间</th><th>报告</th><th>问题</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;
}
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 }) =>
`<tr><td>${suite.order + 1}</td><td>${escapeHtml(suite.label)}</td><td>${aggregate?.execution?.completedCaseCount ?? 0}/140</td><td class="${aggregate?.gatePassed ? "passed" : "pending"}">${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"}</td><td><a href="${escapeHtml(suite.outputDirectoryName)}/aggregate.html">报告</a></td></tr>`
).join("");
fs.writeFileSync(path.join(outputRoot, "index.html"), `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>DOCX 4×140 发布门禁</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;min-width:900px}th,td{border:1px solid #d0d5dd;padding:10px;text-align:left}.passed{background:#e6f7ec}.pending{background:#fff4cc}</style></head><body><h1>DOCX 4×140 独立发布门禁</h1><p>先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。</p><table><thead><tr><th>顺序</th><th>套件</th><th>进度</th><th>状态</th><th>独立报告</th></tr></thead><tbody>${rows}</tbody></table></body></html>`, "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;
}