feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+4
View File
@@ -1,5 +1,6 @@
import {
exportConfigSchema,
semanticDocumentModelSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
@@ -66,6 +67,9 @@ export function parsePagedDocumentPayload(
),
language: readString(metadata, "language", 50)
},
semanticDocument: semanticDocumentModelSchema.parse(
value.semanticDocument
),
features: parsedFeatures,
themeCss: readString(
value,
@@ -15,6 +15,7 @@ const plan: DocxMediaCapturePlan = {
ordinal: 1,
kindOrdinal: 1,
altText: "图片",
alignment: "center",
displayWidthPx: 200,
displayHeightPx: 100,
captureX: 12,
@@ -33,6 +33,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
+2
View File
@@ -10,6 +10,7 @@
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-http": "node scripts/verify-docx-http.mjs",
"verify:docx-r4-matrix": "node scripts/verify-docx-r4-matrix.mjs",
"verify:docx-theme-styles": "node scripts/verify-docx-theme-styles.mjs"
},
"dependencies": {
@@ -18,6 +19,7 @@
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fflate": "0.8.3",
"fastify": "^5.6.2",
"playwright": "1.62.0"
},
+1
View File
@@ -103,6 +103,7 @@ const computedStyle = {
borderLeft: "0px none rgb(34, 34, 34)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -0,0 +1,489 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate";
import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const themesDirectory = path.join(repositoryDirectory, "themes");
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
const outputDirectory = path.resolve(
process.env.MD_TO_PDF_R4_OUTPUT_DIR?.trim() ||
path.join(repositoryDirectory, "output", "docx-r4")
);
const pdfDirectory = path.join(outputDirectory, "chromium-pdf");
const docxDirectory = path.join(outputDirectory, "docx");
const standardReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-matrix",
"theme-matrix-report.json"
);
const fontPackRoot = path.resolve(
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
path.join(repositoryDirectory, "output", "font-packs", "root")
);
const decoder = new TextDecoder();
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExportConfigOverride() {
const source = process.env.MD_TO_PDF_R4_EXPORT_CONFIG_JSON?.trim();
if (!source) {
return undefined;
}
const parsed = JSON.parse(source);
assert(
parsed && typeof parsed === "object" && !Array.isArray(parsed),
"R4 导出配置覆盖必须是 JSON 对象"
);
return parsed;
}
function mergeExportConfig(base, override) {
if (!override) {
return base;
}
return {
...base,
...override,
mermaid: { ...base.mermaid, ...override.mermaid },
paper: {
...base.paper,
...override.paper,
margins: { ...base.paper.margins, ...override.paper?.margins }
},
header: {
...base.header,
...override.header,
left: { ...base.header.left, ...override.header?.left },
center: { ...base.header.center, ...override.header?.center },
right: { ...base.header.right, ...override.header?.right }
},
footer: { ...base.footer, ...override.footer },
metadata: { ...base.metadata, ...override.metadata },
print: { ...base.print, ...override.print }
};
}
function readThemes() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(themesDirectory, entry.name, "theme.json"))
)
.map((entry) =>
themeManifestSchema.parse(
readJson(path.join(themesDirectory, entry.name, "theme.json"))
)
)
.sort((first, second) => first.id.localeCompare(second.id, "en"));
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配 R4 验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) => {
const content = fs.readFileSync(resolveWebFile("preview-frame.html"));
return reply.type("text/html; charset=utf-8").send(content);
});
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
const stat = fs.statSync(filePath);
assert(stat.isFile(), `Web 资源不是普通文件:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function inspectDocx(content) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(entries["word/fontTable.xml"]);
const fontParts = Object.keys(entries).filter((name) =>
name.startsWith("word/fonts/")
);
const fontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
].map((match) => match[1]);
const paragraphs = [
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
].map((match) => match[0]);
const authorParagraphCount = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="Author"\s*\/>/u.test(paragraph)
).length;
const officialIssueRows = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="MdOfficialIssueRow"\s*\/>/u.test(
paragraph
)
);
const officialIssueRowsWithRightTab = officialIssueRows.filter(
(paragraph) =>
[...paragraph.matchAll(/<w:tab(?:\s[^>]*)?\s*\/>/gu)].some(
(match) =>
/\bw:val="right"/u.test(match[0]) &&
/\bw:pos="\d+"/u.test(match[0])
)
);
const officialIssueRowContentTabCount = officialIssueRows.reduce(
(count, paragraph) =>
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
0
);
return {
bytes: content.byteLength,
sha256: sha256(content),
partCount: Object.keys(entries).length,
sectionCount: documentXml.match(/<w:sectPr(?:\s|>)/gu)?.length ?? 0,
embeddedFontPartCount: fontParts.length,
fontNames: [...new Set(fontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
internalMarkerCount:
documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0,
authorParagraphCount,
officialIssueRowCount: officialIssueRows.length,
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
requiredStylesPresent: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
].every((styleId) => stylesXml.includes(`w:styleId="${styleId}"`))
};
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
return { response, content };
}
function responseDiagnostics(response, kind) {
return {
serverTiming: response.headers.get("server-timing"),
pageCount:
kind === "pdf"
? Number(response.headers.get("x-pdf-page-count"))
: undefined,
warningCount:
kind === "docx"
? Number(response.headers.get("x-docx-warning-count"))
: undefined,
echartsErrorCount: Number(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: Number(
response.headers.get("x-mermaid-error-count")
)
};
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
);
const allThemes = readThemes();
assert(
allThemes.length === 14,
`内置主题数量应为 14,实际为 ${allThemes.length}`
);
const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
const exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId)
: allThemes;
assert(
themes.length > 0,
`R4 验收主题不存在:${selectedThemeId}`
);
const standardReport = readJson(standardReportPath);
assert(
standardReport.themeCount === allThemes.length,
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
);
const standardByTheme = new Map(
standardReport.results.map((result) => [result.id, result])
);
fs.mkdirSync(pdfDirectory, { recursive: true });
fs.mkdirSync(docxDirectory, { recursive: true });
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion: "0.6.0"
}
});
registerWebRuntime(app);
const results = [];
try {
await app.listen({ port, host: "127.0.0.1" });
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
for (const theme of themes) {
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const markdown = fs.readFileSync(samplePath, "utf8");
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
themeId: theme.id,
pageDecorationsMode: "theme",
header: structuredClone(
theme.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme.pageDefaults?.footer ?? defaultExportConfig.footer
),
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme",
margins: structuredClone(
theme.pageDefaults?.margins ??
defaultExportConfig.paper.margins
)
}
}, exportConfigOverride);
const payload = {
markdown,
fileName: `${theme.id}.md`,
language: "zh-CN",
resources: [],
exportConfig
};
const pdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
assert(
decoder.decode(pdf.content.subarray(0, 5)) === "%PDF-",
`主题 ${theme.id} 的 PDF 签名无效`
);
const docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
const inspection = inspectDocx(docx.content);
assert(!inspection.hasAltChunk, `主题 ${theme.id} 包含 altChunk`);
assert(
inspection.internalMarkerCount === 0,
`主题 ${theme.id} 残留内部结构标记`
);
assert(
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
standard.inspection.structureCoverage === 1 &&
standard.inspection.titlePolicyPassed &&
standard.inspection.titleDeduplicationPassed,
`主题 ${theme.id} 的标准结构门禁未通过`
);
if (standard.inspection.profile) {
assert(
inspection.authorParagraphCount === 0,
`结构化主题 ${theme.id} 残留重复 Author 段落`
);
}
const expectsOfficialDualEndedRow = Boolean(
standard.metadata?.document?.profile === "official" &&
standard.metadata.document.signatory
);
if (expectsOfficialDualEndedRow) {
assert(
inspection.officialIssueRowCount > 0 &&
inspection.officialIssueRowCount ===
inspection.officialIssueRowRightTabCount &&
inspection.officialIssueRowContentTabCount ===
inspection.officialIssueRowCount,
`主题 ${theme.id} 的公文双端行缺少右制表位或签发人分隔`
);
} else {
assert(
inspection.officialIssueRowRightTabCount === 0 &&
inspection.officialIssueRowContentTabCount === 0,
`主题 ${theme.id} 的单端公文行包含无意义制表位`
);
}
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
const docxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
fs.writeFileSync(docxPath, docx.content);
results.push({
id: theme.id,
name: theme.name,
category: theme.category,
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength,
sha256: sha256(pdf.content),
diagnostics: responseDiagnostics(pdf.response, "pdf")
},
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
expectsOfficialDualEndedRow
});
}
} finally {
await app.close();
}
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
if (themes.some((theme) => theme.id === "gov-red-standard")) {
assert(
enhancedThemes.some((result) => result.id === "gov-red-standard"),
"有效字体包未增强 gov-red-standard"
);
}
for (const result of results) {
assert(
result.pdf.diagnostics.pageCount > 0,
`主题 ${result.id} 的 PDF 页数无效`
);
assert(
result.pdf.diagnostics.echartsErrorCount === 0 &&
result.pdf.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 PDF 图表渲染失败`
);
assert(
result.docx.diagnostics.echartsErrorCount === 0 &&
result.docx.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 DOCX 图表渲染失败`
);
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
origin,
themeCount: themes.length,
totalThemeCount: allThemes.length,
selectedThemeId,
fontPackRoot,
standardReport: path.relative(repositoryDirectory, standardReportPath),
outputDirectory: path.relative(repositoryDirectory, outputDirectory),
enhancedThemeIds: enhancedThemes.map((result) => result.id),
results
};
const reportPath = path.join(outputDirectory, "r4-matrix-report.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -15,6 +15,7 @@ const plan: DocxMediaCapturePlan = {
ordinal: 1,
kindOrdinal: 1,
altText: "图表",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 180,
captureX: 10,
@@ -33,6 +33,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
+14 -1
View File
@@ -731,6 +731,19 @@ export function ExportSettingsDrawer({
</div>
);
})}
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.header.showOnFirstPage}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control">
<input
type="checkbox"
@@ -854,7 +867,7 @@ export function ExportSettingsDrawer({
})
}
/>
<span></span>
<span></span>
</label>
<label className="switch-control divider-control">
<input
+3 -1
View File
@@ -109,7 +109,8 @@ describe("导出设置缓存", () => {
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
fontFamily: undefined,
showOnFirstPage: undefined
},
footer: {
...defaultExportConfig.footer,
@@ -123,6 +124,7 @@ describe("导出设置缓存", () => {
expect(migrated.pageDecorationsMode).toBe("theme");
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
expect(migrated.header.showOnFirstPage).toBe(true);
expect(migrated.footer.showOnFirstPage).toBe(true);
});
+3 -1
View File
@@ -37,7 +37,8 @@ const officialTheme = createTheme("official", {
...formalTheme.pageDefaults,
header: {
...defaultExportConfig.header,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
showOnFirstPage: false
},
footer: {
...defaultExportConfig.footer,
@@ -105,6 +106,7 @@ describe("主题推荐页边距", () => {
expect(selected.pageDecorationsMode).toBe("theme");
expect(selected.header.fontFamily).toContain("Fandol Song");
expect(selected.header.showOnFirstPage).toBe(false);
expect(selected.footer).toMatchObject({
alignment: "outer",
format: "official-page",
+64 -2
View File
@@ -388,6 +388,62 @@ Filter、只读挂载以及 Docker 构建上下文排除。统一真实门禁为
生产构建和 `git diff --check` 均通过。下一步 R4 进入 14 套主题深度视觉
回归。
阶段 12D-R4-2 已建立可复现的全主题生产链矩阵。统一命令
`npm run verify:docx-r4-matrix` 会先重新生成 14 套主题的标准 DOCX 结构
基线并复验可选字体包,再构建同源 Web 预览运行时,通过真实 Fastify HTTP
接口和 Playwright Chromium 分别生成 14 份 PDF 与 14 份 DOCX。产物与
结构化报告写入被 Git 忽略的 `output/docx-r4/`,供后续 Word、WPS 和视觉
差异阶段复用。
本轮生产链共生成 28 页 Chromium PDF14/14 DOCX 均保留标准 Word
段落、标题、代码、表格和图注样式,不含 `altChunk` 或内部结构标记,
Front Matter、封面分节、标题去重及表格全宽继续通过标准矩阵门禁。
PDF 与 DOCX 的 Mermaid、ECharts 错误均为 0`gov-red-standard` 通过
Server 字体包注册链命中 `mdtp-serif-sc@1.0.0`,字体表包含
`MdTP Serif SC` 并实际嵌入 5 个字体部件。3 条诊断仍是已冻结的商务标书
超粗边框和经典标书双层边框结构近似。Server 44 项测试、类型检查、构建
`git diff --check` 通过。下一步 R4-3 使用 Microsoft Word 对 14 份
生产 DOCX 执行无修复打开、编辑、保存和 PDF 导出。
阶段 12D-R4 媒体专项已完成生产链、OOXML、Word/WPS 编辑互存和视觉
门禁。媒体捕获协议现在记录实际显示尺寸、内容区边界和左右/居中对齐,
Pandoc 媒体布局使用稳定绑定标记与整数 EMU;最终 DrawingML 强制
`wp:inline`、零环绕距离、锁定宽高比、无裁切/旋转/翻转,并校验 PNG
关系、替代文本、孤立部件和纸张内容区上限。普通图片、宽图、高图、
Mermaid 与 ECharts 共 5 个媒体在 Word/WPS 中均可编辑;自动化将首图
缩放至 90% 后原位保存并重开,Word 非目标图尺寸漂移为 0,WPS 最大仅
0.046 pt,且两端没有浮动图片或额外图片关系。
统一命令 `npm run verify:docx-media-visuals` 会通过真实 Server HTTP 生成
Chromium PDF 与 DOCX,再由 Word/WPS 导出 PDF、编辑互存并复导 PDF;
三份 PDF 使用独立 PDF.js worker 生成页面快照、叠加图和热力图,避免
多文档进程级状态污染。五色几何门禁检查物理尺寸、宽高比和水平中心;
高图允许 Chromium 媒体回填与 DOCX 限高策略产生尺寸差异,但仍强制比例、
对齐及 Word/WPS 物理尺寸一致。4 条图片题注在 Chromium、Word、WPS
三端均要求距页面中心不超过 8 pt`ImageCaption` 已固定为居中,同时
保留主题提供的字体、字号、颜色和行距。Chromium 为 4 页,Word/WPS 为
5 页,属于已允许的自然分页差异;三端可编辑文本完整。原始逐页像素报告
仍如实标记页数与正文流差异,媒体专项门禁按上述契约通过。产物位于
`output/docx-media-gate/`
阶段 12D-R4 布局视觉矩阵已完成。统一命令
`npm run verify:docx-layout-visual-matrix` 覆盖 A4/Letter、纵向/横向、
主题/自定义及非对称页边距、普通文档、公文和两类独立封面,通过真实
Chromium、Microsoft Word 与 WPS Writer 生成逐页原图、叠加图、热力图和
结构化报告。发布门禁明确以元素颗粒度为准:字体、字重、颜色、尺寸、
段落与代码内边距、标题和封面换行、表格边框与填充、图片尺寸和对齐、
页眉页脚及页码位置属于严格项;Word/WPS 与 Chromium 的正文分页位置、
页数和由排版引擎产生的自然行容纳差异仅作为诊断,不单独阻断。封面仍
必须保持独立单页并在正文前强制换页。
六组矩阵的 Word/WPS 可编辑文本均精确匹配,严格问题为 0。视觉审查发现
主题普遍将折叠表格边框声明在 `th/td` 而非 `table`:转换器此前误用旧
模板的浅色细边框。本轮已将单元格四边通用映射到 OOXML 外框和
`insideH/insideV`,保留表自身显式边框优先级;横向规章制度主题重新渲染
后,Chromium、Word、WPS 的网格颜色、线宽和表头填充一致。随后重跑
14 套主题生产矩阵,全部完成同源 PDF/DOCX、结构、字体包、封面分节、
标题去重、表格全宽、媒体和 OOXML 校验。
## 2. 已完成
### 2.1 项目骨架
@@ -1348,8 +1404,14 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- 阶段 12D-FP3-B:已完成独立用户级 NSIS 字体包、原子安装/升级/卸载、
主安装器同目录哈希校验和显式静默开关;
- 阶段 12D-FP3-C:已完成 Docker v0.6.0 DOCX/Pandoc 运行链、字体包只读
可选挂载及空目录、有效包、损坏包三场景真实部署门禁;下一步由 R4 完成
14 套主题深度视觉回归;
可选挂载及空目录、有效包、损坏包三场景真实部署门禁;
- 阶段 12D-R4-2:已完成 14 套主题的标准结构基线、可选字体包、真实
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
- 阶段 12D-R4 布局视觉矩阵:已完成六组主题、纸张、方向与页边距组合的
Chromium/Word/WPS 元素级严格门禁,并重跑 14 套主题生产矩阵;分页流
差异保留为诊断项,不再误判为样式失败;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+1
View File
@@ -530,6 +530,7 @@
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2",
"fflate": "0.8.3",
"playwright": "1.62.0"
},
"devDependencies": {
+4
View File
@@ -39,6 +39,10 @@
"verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop",
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
"verify:docx-r4-matrix": "npm run verify:docx-themes && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-r4-matrix -w @md-to-pdf/server",
"verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs",
"verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs",
"verify:docx-layout-visual-matrix": "npm run verify:docx-r4-matrix && npm run build -w @md-to-pdf/document-visual-diff && node scripts/verify-docx-layout-visual-matrix.mjs",
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/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"
},
@@ -199,6 +199,40 @@ describe("内置主题清单", () => {
}
});
it("可研封面使用跨 Chromium 与 Word 一致的原生字体面", async () => {
const [css, manifestSource] = await Promise.all([
readFile(
new URL(
"../../../themes/formal-feasibility/theme.css",
import.meta.url
),
"utf8"
),
readFile(
new URL(
"../../../themes/formal-feasibility/theme.json",
import.meta.url
),
"utf8"
)
]);
const manifest = JSON.parse(manifestSource) as {
docxFonts?: {
faces?: Array<{ family: string; weight?: number }>;
};
};
expect(css).toContain('"FandolSong", "Noto Serif CJK SC"');
expect(css).toContain('"FandolHei", "Noto Sans CJK SC"');
expect(manifest.docxFonts?.faces).toEqual(
expect.arrayContaining([
expect.objectContaining({ family: "FandolSong", weight: 400 }),
expect.objectContaining({ family: "FandolSong", weight: 700 }),
expect.objectContaining({ family: "FandolHei", weight: 400 })
])
);
});
it("技术暗标主题隐藏结构化身份字段", async () => {
const css = await readFile(
new URL(
@@ -99,6 +99,7 @@ function createPlan(): DocxMediaCapturePlan {
kindOrdinal: 1,
altText: "收入趋势",
caption: "年度收入",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 160,
captureX: 0,
+2
View File
@@ -26,6 +26,7 @@ export interface PagedDocumentPayload {
articleHtml: string;
fileName: string;
metadata: MarkdownDocumentMetadata;
semanticDocument: SemanticDocumentModel;
features: ThemeFeature[];
themeCss: string;
exportConfig: ExportConfig;
@@ -48,6 +49,7 @@ export function createPagedDocumentPayload({
articleHtml: document.articleHtml,
fileName,
metadata: document.metadata,
semanticDocument: document.semanticDocument,
features: document.features,
themeCss,
exportConfig
+11
View File
@@ -96,6 +96,16 @@ export const docxMediaKindSchema = z.enum([
export type DocxMediaKind = z.infer<typeof docxMediaKindSchema>;
export const docxMediaAlignmentSchema = z.enum([
"left",
"center",
"right"
]);
export type DocxMediaAlignment = z.infer<
typeof docxMediaAlignmentSchema
>;
export const docxMediaCaptureTargetSchema = z.object({
id: z.string().regex(/^docx-media-\d+$/u),
kind: docxMediaKindSchema,
@@ -107,6 +117,7 @@ export const docxMediaCaptureTargetSchema = z.object({
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
altText: z.string().max(1_000),
caption: z.string().max(1_000).optional(),
alignment: docxMediaAlignmentSchema,
displayWidthPx: z.number().positive().max(10_000),
displayHeightPx: z.number().positive().max(10_000),
captureX: z.number().int().nonnegative().max(100_000),
+4 -2
View File
@@ -51,8 +51,8 @@ export const paperDimensionsMm: Record<
A3: { width: 297, height: 420 },
A4: { width: 210, height: 297 },
A5: { width: 148, height: 210 },
Letter: { width: 216, height: 279 },
Legal: { width: 216, height: 356 }
Letter: { width: 215.9, height: 279.4 },
Legal: { width: 215.9, height: 355.6 }
};
const lengthSchema = z
@@ -114,6 +114,7 @@ const pageDecorationFontFamilySchema = z
export const headerSchema = z.object({
enabled: z.boolean(),
height: lengthSchema,
showOnFirstPage: z.boolean().default(true),
showDivider: z.boolean(),
fontSize: lengthSchema,
fontFamily: pageDecorationFontFamilySchema.default(
@@ -289,6 +290,7 @@ export const defaultExportConfig: ExportConfig = {
header: {
enabled: false,
height: "8mm",
showOnFirstPage: true,
showDivider: false,
fontSize: "3mm",
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
+2
View File
@@ -60,6 +60,7 @@ export interface SemanticDocumentTextNode {
export interface SemanticDocumentGroupNode {
kind: "group";
role: SemanticDocumentRole;
layout?: "space-between" | undefined;
children: SemanticDocumentNode[];
}
@@ -80,6 +81,7 @@ export const semanticDocumentNodeSchema: z.ZodType<
z.object({
kind: z.literal("group"),
role: semanticDocumentRoleSchema,
layout: z.literal("space-between").optional(),
children: z.array(semanticDocumentNodeSchema).min(1).max(30)
})
])
+1
View File
@@ -41,6 +41,7 @@ describe("分页文档载荷", () => {
articleHtml: document.articleHtml,
fileName: "测试.md",
metadata: document.metadata,
semanticDocument: document.semanticDocument,
features: document.features,
themeCss: "#write { color: #333; }",
exportConfig: defaultExportConfig
+2
View File
@@ -129,6 +129,7 @@ describe("DOCX 共享协议", () => {
kindOrdinal: 1,
altText: "流程图",
caption: "处理流程",
alignment: "center",
displayWidthPx: 640,
displayHeightPx: 320,
captureX: 20,
@@ -151,6 +152,7 @@ describe("DOCX 共享协议", () => {
ordinal: 1,
kindOrdinal: 1,
altText: "",
alignment: "center",
displayWidthPx: 100,
displayHeightPx: 100,
captureX: 0,
+40 -4
View File
@@ -27,8 +27,23 @@ describe("导出配置", () => {
A3: { width: 297, height: 420 },
A4: { width: 210, height: 297 },
A5: { width: 148, height: 210 },
Letter: { width: 216, height: 279 },
Legal: { width: 216, height: 356 }
Letter: { width: 215.9, height: 279.4 },
Legal: { width: 215.9, height: 355.6 }
});
});
it("保持英制纸张的标准尺寸精度", () => {
expect(getPaperDimensionsMm("Letter", "portrait")).toEqual({
width: 215.9,
height: 279.4
});
expect(getPaperDimensionsMm("Letter", "landscape")).toEqual({
width: 279.4,
height: 215.9
});
expect(getPaperDimensionsMm("Legal", "portrait")).toEqual({
width: 215.9,
height: 355.6
});
});
@@ -51,7 +66,8 @@ describe("导出配置", () => {
it("提供可由主题推荐的页眉和页码配置", () => {
expect(defaultExportConfig.header).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif'
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
showOnFirstPage: true
});
expect(defaultExportConfig.footer).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
@@ -65,7 +81,8 @@ describe("导出配置", () => {
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
fontFamily: undefined,
showOnFirstPage: undefined
},
footer: {
...defaultExportConfig.footer,
@@ -76,10 +93,29 @@ describe("导出配置", () => {
expect(parsedLegacyV4.pageDecorationsMode).toBe("theme");
expect(parsedLegacyV4.header.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.header.showOnFirstPage).toBe(true);
expect(parsedLegacyV4.footer.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.footer.showOnFirstPage).toBe(true);
});
it("允许页眉和页码独立控制正文首页可见性", () => {
const parsed = exportConfigSchema.parse({
...defaultExportConfig,
header: {
...defaultExportConfig.header,
enabled: true,
showOnFirstPage: false
},
footer: {
...defaultExportConfig.footer,
showOnFirstPage: true
}
});
expect(parsed.header.showOnFirstPage).toBe(false);
expect(parsed.footer.showOnFirstPage).toBe(true);
});
it("支持公文页码格式和奇偶页外侧对齐", () => {
expect(
exportConfigSchema.safeParse({
+39
View File
@@ -27,6 +27,7 @@ describe("主题清单", () => {
header: {
enabled: false,
height: "8mm",
showOnFirstPage: false,
showDivider: false,
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
@@ -56,6 +57,44 @@ describe("主题清单", () => {
format: "official-page",
showOnFirstPage: false
});
expect(parsed.pageDefaults?.header.showOnFirstPage).toBe(false);
});
it("为旧主题的页眉首页策略补齐兼容默认值", () => {
const parsed = themeManifestSchema.parse({
manifestVersion: 1,
id: "legacy-header-test",
name: "旧主题页眉测试",
version: "0.5.1",
description: "验证未声明首页策略的旧主题。",
author: "md-to-pdf contributors",
license: "项目自有",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "16px",
supportedFeatures: [],
pageDefaults: {
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
},
header: {
enabled: true,
height: "8mm",
showDivider: false,
fontSize: "3mm",
color: "#000000",
left: { enabled: false, content: "" },
center: { enabled: true, content: "${title}" },
right: { enabled: false, content: "" }
}
},
bundled: true
});
expect(parsed.pageDefaults?.header?.showOnFirstPage).toBe(true);
});
it("允许声明可嵌入的本地 DOCX 字体字形面", () => {
+231 -8
View File
@@ -1,5 +1,12 @@
import { normalizePdfContentText } from "./text.js";
import {
buildPdfEditableContentText,
normalizePdfContentText,
normalizePdfEditableText,
} from "./text.js";
import { comparePdfEditableParagraphLayouts } from "./paragraph-layout.js";
import { comparePdfPageSemantics } from "./page-semantics.js";
import type {
PdfEditableContentComparisonOptions,
PdfBasicComparison,
PdfDocumentSnapshot,
PdfPagePair,
@@ -78,6 +85,27 @@ export function calculateContentSimilarity(
return Math.max(0, 1 - distance / maximumLength);
}
export function calculateOrderedContentCoverage(
expectedInput: string,
actualInput: string,
): number {
const expected = normalizePdfEditableText(expectedInput);
const actual = normalizePdfEditableText(actualInput);
if (expected.length === 0) {
return 1;
}
let matched = 0;
for (const character of actual) {
if (character === expected[matched]) {
matched += 1;
if (matched === expected.length) {
break;
}
}
}
return matched / expected.length;
}
function createPagePairs(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
@@ -111,10 +139,32 @@ function createPagePairs(
});
}
function pageNumbersEqual(
left: readonly number[],
right: readonly number[],
): boolean {
return left.length === right.length &&
left.every((value, index) => value === right[index]);
}
function coverPageCount(
semantics: readonly { kind: string }[],
): number {
return semantics.filter((page) => page.kind === "cover").length;
}
function bodyPageNumbers(pageCount: number, coverCount: number): number[] {
return Array.from(
{ length: Math.max(0, pageCount - coverCount) },
(_, index) => coverCount + index + 1,
);
}
export function comparePdfSnapshotsBasic(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholdOverrides: Partial<VisualDiffThresholds> = {},
contentOptions: PdfEditableContentComparisonOptions = {},
): PdfBasicComparison {
const thresholds = {
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
@@ -122,6 +172,10 @@ export function comparePdfSnapshotsBasic(
};
const issues: VisualDiffIssue[] = [];
const pagePairs = createPagePairs(baseline, candidate);
const baselinePageSemantics =
contentOptions.baselinePageSemantics ?? contentOptions.pageSemantics ?? [];
const candidatePageSemantics =
contentOptions.candidatePageSemantics ?? contentOptions.pageSemantics ?? [];
if (baseline.pageCount !== candidate.pageCount) {
issues.push({
@@ -193,17 +247,175 @@ export function comparePdfSnapshotsBasic(
}
}
const contentSimilarity = calculateContentSimilarity(
baseline.contentText,
candidate.contentText,
thresholds.contentSimilarity,
const hasEditableExpectation =
contentOptions.expectedEditableText !== undefined;
const expectedEditableText = normalizePdfEditableText(
contentOptions.expectedEditableText ?? "",
);
if (contentSimilarity < thresholds.contentSimilarity) {
const baselineEditableText = hasEditableExpectation
? buildPdfEditableContentText(baseline, baselinePageSemantics)
: "";
const candidateEditableText = hasEditableExpectation
? buildPdfEditableContentText(candidate, candidatePageSemantics)
: "";
const baselineEditableCoverage = hasEditableExpectation
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
: undefined;
const candidateEditableSimilarity = hasEditableExpectation
? calculateContentSimilarity(
expectedEditableText,
candidateEditableText,
thresholds.contentSimilarity,
)
: undefined;
const candidateEditableExact = hasEditableExpectation
? candidateEditableText === expectedEditableText
: undefined;
const contentSimilarity = hasEditableExpectation
? Math.min(
baselineEditableCoverage ?? 0,
candidateEditableSimilarity ?? 0,
)
: calculateContentSimilarity(
baseline.contentText,
candidate.contentText,
thresholds.contentSimilarity,
);
const contentMismatch = hasEditableExpectation
? baselineEditableCoverage !== 1 || candidateEditableExact !== true
: contentSimilarity < thresholds.contentSimilarity;
if (contentMismatch) {
issues.push({
code: "CONTENT_MISMATCH",
severity: "failure",
message: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`,
details: { contentSimilarity },
message: hasEditableExpectation
? `可编辑正文门禁失败:Chromium 有序覆盖 ${((baselineEditableCoverage ?? 0) * 100).toFixed(3)}%,候选相似度 ${((candidateEditableSimilarity ?? 0) * 100).toFixed(3)}%,候选精确匹配 ${candidateEditableExact ? "是" : "否"}`
: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`,
details: hasEditableExpectation
? {
baselineEditableCoverage: baselineEditableCoverage ?? 0,
candidateEditableSimilarity: candidateEditableSimilarity ?? 0,
candidateEditableExact: candidateEditableExact ?? false,
}
: { contentSimilarity },
});
}
const paragraphLayouts = contentOptions.expectedEditableParagraphs
? comparePdfEditableParagraphLayouts(
baseline,
candidate,
contentOptions.expectedEditableParagraphs,
baselinePageSemantics,
candidatePageSemantics,
)
: undefined;
if (paragraphLayouts) {
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
}
const baselineCoverPageCount = coverPageCount(baselinePageSemantics);
const candidateCoverPageCount = coverPageCount(candidatePageSemantics);
const baselineBodyPageCount = baseline.pageCount - baselineCoverPageCount;
const candidateBodyPageCount = candidate.pageCount - candidateCoverPageCount;
const movedParagraphs = (paragraphLayouts ?? []).filter(
(paragraph) =>
paragraph.expectation.section === "body" &&
!pageNumbersEqual(
paragraph.baseline.pageNumbers,
paragraph.candidate.pageNumbers,
),
);
const bodyPageCountChanged = baselineBodyPageCount !== candidateBodyPageCount;
const bodyFlowDetected = movedParagraphs.length > 0 || bodyPageCountChanged;
const affectedBaselinePageNumbers = bodyPageCountChanged
? bodyPageNumbers(baseline.pageCount, baselineCoverPageCount)
: [...new Set(movedParagraphs.flatMap((item) => item.baseline.pageNumbers))]
.sort((left, right) => left - right);
const affectedCandidatePageNumbers = bodyPageCountChanged
? bodyPageNumbers(candidate.pageCount, candidateCoverPageCount)
: [...new Set(movedParagraphs.flatMap((item) => item.candidate.pageNumbers))]
.sort((left, right) => left - right);
const hasStructuralPageFailure = issues.some((issue) =>
issue.code === "PAGE_SIZE_MISMATCH" ||
issue.code === "PAGE_ORIENTATION_MISMATCH"
);
const hasCompletePageSemantics =
baselinePageSemantics.length === baseline.pageCount &&
candidatePageSemantics.length === candidate.pageCount;
const pageSemanticsPassed =
hasCompletePageSemantics &&
pagePairs.every((pair) => {
const baselineExpectation =
pair.status === "candidate-only"
? undefined
: baselinePageSemantics.find(
(item) =>
item.physicalPageNumber === pair.baselinePageNumber,
);
const candidateExpectation =
pair.status === "baseline-only"
? undefined
: candidatePageSemantics.find(
(item) =>
item.physicalPageNumber === pair.candidatePageNumber,
);
const primaryExpectation =
baselineExpectation ?? candidateExpectation;
if (!primaryExpectation) {
return false;
}
return comparePdfPageSemantics(
baseline,
candidate,
primaryExpectation,
candidateExpectation ?? primaryExpectation,
).status === "passed";
});
const bodyFlowLegal =
bodyFlowDetected &&
paragraphLayouts !== undefined &&
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
baselineEditableCoverage === 1 &&
candidateEditableExact === true &&
baselineCoverPageCount === candidateCoverPageCount &&
!hasStructuralPageFailure &&
pageSemanticsPassed;
const bodyFlow = paragraphLayouts
? {
detected: bodyFlowDetected,
legal: bodyFlowLegal,
baselineCoverPageCount,
candidateCoverPageCount,
baselineBodyPageCount,
candidateBodyPageCount,
movedParagraphIndexes: movedParagraphs.map(
(paragraph) => paragraph.expectation.index,
),
affectedBaselinePageNumbers,
affectedCandidatePageNumbers,
}
: undefined;
if (bodyFlowLegal && bodyPageCountChanged) {
for (let index = issues.length - 1; index >= 0; index -= 1) {
const issue = issues[index];
if (
issue?.code === "PAGE_COUNT_MISMATCH" ||
issue?.code === "UNPAIRED_BASELINE_PAGE" ||
issue?.code === "UNPAIRED_CANDIDATE_PAGE"
) {
issues.splice(index, 1);
}
}
issues.push({
code: "BODY_FLOW_PAGE_COUNT_DIFFERENCE",
severity: "warning",
message: `正文自然分页导致页数不同:基线 ${baselineBodyPageCount} 页,候选 ${candidateBodyPageCount}`,
details: {
baselineBodyPageCount,
candidateBodyPageCount,
},
});
}
@@ -219,6 +431,17 @@ export function comparePdfSnapshotsBasic(
candidate: candidate.source,
thresholds,
contentSimilarity,
...(baselineEditableCoverage === undefined
? {}
: { baselineEditableCoverage }),
...(candidateEditableSimilarity === undefined
? {}
: { candidateEditableSimilarity }),
...(candidateEditableExact === undefined
? {}
: { candidateEditableExact }),
...(paragraphLayouts === undefined ? {} : { paragraphLayouts }),
...(bodyFlow === undefined ? {} : { bodyFlow }),
pagePairs,
issues,
};
@@ -2,6 +2,8 @@ export * from "./adapter-runner.js";
export * from "./adapters.js";
export * from "./compare.js";
export * from "./office-adapter.js";
export * from "./paragraph-layout.js";
export * from "./page-semantics.js";
export * from "./raster.js";
export * from "./report.js";
export * from "./snapshot.js";
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { basename, dirname, extname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
@@ -28,6 +28,37 @@ export interface OfficeExportMetadata {
bytes: number;
}
export interface OfficeInlineShapeObservation {
index: number;
widthPt: number;
heightPt: number;
type?: number;
}
export interface OfficeDocxRoundTripMetadata {
bytes: number;
before: OfficeInlineShapeObservation[];
saved: OfficeInlineShapeObservation[];
resizedShapeIndex: number;
resizeScale: number;
}
export interface OfficeDocxRoundTripGeneration
extends OfficeDocxRoundTripMetadata {
client: OfficeClientKind;
docx: Uint8Array;
elapsedMs: number;
}
export interface OfficeDocxRoundTripAdapter {
id: string;
client: OfficeClientKind;
probe(): Promise<PdfAdapterCapability>;
generate(
input: OfficePdfAdapterInput,
): Promise<OfficeDocxRoundTripGeneration>;
}
export interface OfficeAutomationBackend {
probe(progId: string, timeoutMs: number): Promise<boolean>;
exportPdf(options: {
@@ -37,6 +68,14 @@ export interface OfficeAutomationBackend {
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata>;
roundTripDocx?(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
resizeScale: number;
}): Promise<OfficeDocxRoundTripMetadata>;
}
export interface OfficePdfAdapterOptions {
@@ -116,6 +155,110 @@ try {
}
`;
const POWERSHELL_ROUND_TRIP_SCRIPT = `
$ErrorActionPreference = 'Stop'
$client = $env:MD_TO_PDF_OFFICE_CLIENT
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$resizeScale = [double]::Parse(
$env:MD_TO_PDF_OFFICE_RESIZE_SCALE,
[Globalization.CultureInfo]::InvariantCulture
)
$resolvedInput = (Resolve-Path -LiteralPath $env:MD_TO_PDF_OFFICE_INPUT).Path
$resolvedOutput = [System.IO.Path]::GetFullPath($env:MD_TO_PDF_OFFICE_OUTPUT)
if ([System.IO.File]::Exists($resolvedOutput)) {
throw "Office DOCX 互存输出已存在:$resolvedOutput"
}
[System.IO.File]::Copy($resolvedInput, $resolvedOutput, $false)
$application = $null
$document = $null
function Read-InlineShapes($value) {
$result = @()
for ($index = 1; $index -le $value.InlineShapes.Count; $index += 1) {
$shape = $value.InlineShapes.Item($index)
$result += [pscustomobject]@{
index = $index
widthPt = [double]$shape.Width
heightPt = [double]$shape.Height
type = [int]$shape.Type
}
}
return @($result)
}
function Open-Document($application, $client, $path, $readOnly) {
if ($client -eq 'word' -and $readOnly) {
return $application.Documents.OpenNoRepairDialog(
$path, $false, $readOnly, $false, '', '', $false,
'', '', 0, 0, $false, $false, 0, $true
)
}
return $application.Documents.Open($path, $false, $readOnly, $false)
}
try {
$application = New-Object -ComObject $progId
$application.Visible = $false
try { $application.DisplayAlerts = 0 } catch {}
try { $application.AutomationSecurity = 3 } catch {}
$document = Open-Document $application $client $resolvedOutput $false
try { $document.EmbedTrueTypeFonts = $false } catch {}
try { $document.SaveSubsetFonts = $false } catch {}
$before = @(Read-InlineShapes $document)
if ($before.Count -lt 1) {
throw 'Office DOCX 不包含可缩放的内联图片'
}
$shape = $document.InlineShapes.Item(1)
$originalWidth = [double]$shape.Width
$originalHeight = [double]$shape.Height
$shape.Width = [single]($originalWidth * $resizeScale)
$expectedHeight = $originalHeight * $resizeScale
if ([Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5) {
$shape.Height = [single]$expectedHeight
}
if (
[Math]::Abs([double]$shape.Width - ($originalWidth * $resizeScale)) -gt 0.5 -or
[Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5
) {
throw (
'Office 未在内存中按比例缩放首张图片:' +
"readOnly=$($document.ReadOnly), " +
"before=$originalWidth/$originalHeight, " +
"actual=$([double]$shape.Width)/$([double]$shape.Height), " +
"expected=$($originalWidth * $resizeScale)/$expectedHeight"
)
}
$document.Close($true)
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
$document = $null
$document = Open-Document $application $client $resolvedOutput $true
$saved = @(Read-InlineShapes $document)
[pscustomobject]@{
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
before = $before
saved = $saved
resizedShapeIndex = 1
resizeScale = $resizeScale
} | ConvertTo-Json -Compress -Depth 5
} finally {
if ($null -ne $document) {
try { $document.Close($false) } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
}
}
if ($null -ne $application) {
try { $application.Quit() } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$application
) | Out-Null
}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
`;
function encodePowerShell(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
@@ -218,6 +361,82 @@ export class PowerShellOfficeAutomationBackend
: {}),
};
}
async roundTripDocx(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
resizeScale: number;
}): Promise<OfficeDocxRoundTripMetadata> {
const { stdout } = await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_ROUND_TRIP_SCRIPT),
],
{
timeout: options.timeoutMs,
windowsHide: true,
encoding: "utf8",
maxBuffer: 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_OFFICE_CLIENT: options.client,
MD_TO_PDF_OFFICE_PROGID: options.progId,
MD_TO_PDF_OFFICE_INPUT: options.inputPath,
MD_TO_PDF_OFFICE_OUTPUT: options.outputPath,
MD_TO_PDF_OFFICE_RESIZE_SCALE: String(options.resizeScale),
},
},
);
const parsed = parseJsonLine(stdout);
const bytes = parsed.bytes;
const before = parsed.before;
const saved = parsed.saved;
if (
typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
!Array.isArray(before) ||
!Array.isArray(saved)
) {
throw new Error("Office DOCX 互存返回格式无效");
}
const parseShapes = (value: unknown[]) =>
value.map((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
typeof (entry as Record<string, unknown>).index !== "number" ||
typeof (entry as Record<string, unknown>).widthPt !== "number" ||
typeof (entry as Record<string, unknown>).heightPt !== "number"
) {
throw new Error("Office 内联图片观测格式无效");
}
const shape = entry as Record<string, unknown>;
return {
index: shape.index as number,
widthPt: shape.widthPt as number,
heightPt: shape.heightPt as number,
...(typeof shape.type === "number"
? { type: shape.type }
: {}),
};
});
return {
bytes,
before: parseShapes(before),
saved: parseShapes(saved),
resizedShapeIndex: 1,
resizeScale: options.resizeScale,
};
}
}
async function validateDocxPath(inputPath: string): Promise<string> {
@@ -346,3 +565,110 @@ export function createWpsPdfAdapter(
options,
);
}
function createOfficeDocxRoundTripAdapter(
descriptor: OfficeDescriptor,
options: OfficePdfAdapterOptions = {},
): OfficeDocxRoundTripAdapter {
const backend =
options.backend ?? new PowerShellOfficeAutomationBackend();
const timeoutMs = options.timeoutMs ?? 180_000;
const resizeScale = 0.9;
return {
id: `${descriptor.id}-docx-round-trip`,
client: descriptor.client,
async probe() {
const available = await backend.probe(
descriptor.progId,
timeoutMs,
);
return {
available,
adapterId: `${descriptor.id}-docx-round-trip`,
source: {
kind: descriptor.client,
label: `${descriptor.defaultLabel} DOCX 互存`,
},
detail: available
? `已注册 ${descriptor.progId}`
: `未找到 ${descriptor.progId}`,
};
},
async generate(input) {
if (!backend.roundTripDocx) {
throw new Error("Office 自动化后端不支持 DOCX 互存");
}
const inputPath = await validateDocxPath(input.docxPath);
const temporaryDirectory = await mkdtemp(
join(
dirname(inputPath),
`.md-to-pdf-${descriptor.client}-round-trip-`,
),
);
const outputPath = join(
temporaryDirectory,
`${basename(inputPath, extname(inputPath))}-round-trip.docx`,
);
const startedAt = performance.now();
try {
const metadata = await backend.roundTripDocx({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
resizeScale,
});
const outputStat = await stat(outputPath);
if (
outputStat.size <= 0 ||
outputStat.size > MAX_OFFICE_OUTPUT_BYTES ||
outputStat.size !== metadata.bytes
) {
throw new Error(
`Office DOCX 互存输出大小无效:${outputStat.size}`,
);
}
return {
client: descriptor.client,
docx: Uint8Array.from(await readFile(outputPath)),
...metadata,
elapsedMs: performance.now() - startedAt,
};
} finally {
await rm(temporaryDirectory, {
recursive: true,
force: true,
});
}
},
};
}
export function createWordDocxRoundTripAdapter(
options: OfficePdfAdapterOptions = {},
) {
return createOfficeDocxRoundTripAdapter(
{
id: "word",
client: "word",
progId: "Word.Application",
defaultLabel: "Microsoft Word",
},
options,
);
}
export function createWpsDocxRoundTripAdapter(
options: OfficePdfAdapterOptions = {},
) {
return createOfficeDocxRoundTripAdapter(
{
id: "wps",
client: "wps",
progId: "KWPS.Application",
defaultLabel: "WPS Writer",
},
options,
);
}
@@ -0,0 +1,331 @@
import { normalizePdfContentText, normalizePdfText } from "./text.js";
import type {
PdfDocumentSnapshot,
PdfPageDecorationObservation,
PdfPageSemanticComparison,
PdfPageSnapshot,
VisualDiffIssue,
VisualHeaderSlotExpectation,
VisualPageAlignment,
VisualPageSemanticExpectation,
} from "./types.js";
const DECORATION_VERTICAL_TOLERANCE_PT = 2;
function median(values: readonly number[]): number | undefined {
if (values.length === 0) {
return undefined;
}
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
: sorted[middle];
}
function pageNumberBaselineMedian(
document: PdfDocumentSnapshot,
): number | undefined {
return median(
document.pages.flatMap((page) =>
page.lines
.filter((line) => {
const middle = line.bounds.y + line.bounds.height / 2;
return (
line.role === "page-number" && middle >= page.heightPt * 0.85
);
})
.map((line) => line.baselineY),
),
);
}
function lineAlignment(
page: PdfPageSnapshot,
x: number,
width: number,
): VisualPageAlignment {
const middle = x + width / 2;
if (middle < page.widthPt * 0.4) {
return "left";
}
if (middle > page.widthPt * 0.6) {
return "right";
}
return "center";
}
function observePageDecorations(
page: PdfPageSnapshot,
expectation: VisualPageSemanticExpectation,
): PdfPageDecorationObservation {
const bottomPageNumber = page.lines.find((line) => {
const middle = line.bounds.y + line.bounds.height / 2;
return line.role === "page-number" && middle >= page.heightPt * 0.85;
});
const topLines = page.lines.filter(
(line) =>
line.bounds.y + line.bounds.height / 2 <= page.heightPt * 0.12
);
const topText = topLines
.map((line) => normalizePdfContentText(line.normalizedText))
.join("");
const headerSlots = expectation.headerSlots ?? [];
const headerLines = topLines.filter((line) => {
const text = normalizePdfContentText(line.normalizedText);
return headerSlots.some((slot) =>
text.includes(normalizePdfContentText(slot.text))
);
});
const matchedHeaderSlots = headerSlots.filter((slot) =>
topText.includes(normalizePdfContentText(slot.text)),
);
const missingHeaderSlots = headerSlots.filter(
(slot) => !matchedHeaderSlots.includes(slot),
);
return {
...(bottomPageNumber
? {
pageNumberText: bottomPageNumber.normalizedText,
pageNumberAlignment: lineAlignment(
page,
bottomPageNumber.bounds.x,
bottomPageNumber.bounds.width,
),
pageNumberBaselineY: bottomPageNumber.baselineY,
}
: {}),
...(topText ? { headerText: topText } : {}),
...(headerLines.length > 0
? {
headerBaselineY:
headerLines.reduce(
(sum, line) => sum + line.baselineY,
0
) / headerLines.length,
}
: {}),
matchedHeaderSlots,
missingHeaderSlots,
};
}
function compareObservation(
source: "baseline" | "candidate",
pageNumber: number,
expectation: VisualPageSemanticExpectation,
observation: PdfPageDecorationObservation,
): VisualDiffIssue[] {
const issues: VisualDiffIssue[] = [];
const location =
source === "baseline"
? { baselinePageNumber: pageNumber }
: { candidatePageNumber: pageNumber };
const sourceLabel = source === "baseline" ? "基线" : "候选";
const hasHeader = observation.matchedHeaderSlots.length > 0;
if (hasHeader !== expectation.headerVisible) {
issues.push({
code: "HEADER_VISIBILITY_MISMATCH",
severity: "failure",
message: `${sourceLabel}物理第 ${pageNumber} 页页眉可见性错误`,
...location,
details: {
source,
expectedVisible: expectation.headerVisible,
actualVisible: hasHeader,
matchedSlots: observation.matchedHeaderSlots
.map((slot) => slot.alignment)
.join(","),
},
});
}
const hasPageNumber = Boolean(observation.pageNumberText);
if (hasPageNumber !== expectation.footerVisible) {
issues.push({
code: "PAGE_NUMBER_VISIBILITY_MISMATCH",
severity: "failure",
message: `${sourceLabel}物理第 ${pageNumber} 页页码可见性错误`,
...location,
details: {
source,
expectedVisible: expectation.footerVisible,
actualVisible: hasPageNumber,
},
});
}
if (
expectation.footerVisible &&
expectation.pageNumberText &&
observation.pageNumberText &&
normalizePdfText(observation.pageNumberText) !==
normalizePdfText(expectation.pageNumberText)
) {
issues.push({
code: "PAGE_NUMBER_TEXT_MISMATCH",
severity: "failure",
message: `${sourceLabel}物理第 ${pageNumber} 页页码文本错误`,
...location,
details: {
source,
expected: expectation.pageNumberText,
actual: observation.pageNumberText,
},
});
}
if (
expectation.footerVisible &&
expectation.footerAlignment &&
observation.pageNumberAlignment &&
observation.pageNumberAlignment !== expectation.footerAlignment
) {
issues.push({
code: "PAGE_NUMBER_ALIGNMENT_MISMATCH",
severity: "failure",
message: `${sourceLabel}物理第 ${pageNumber} 页页码位置错误`,
...location,
details: {
source,
expected: expectation.footerAlignment,
actual: observation.pageNumberAlignment,
},
});
}
for (const slot of expectation.headerVisible
? observation.missingHeaderSlots
: []) {
issues.push({
code: "HEADER_TEXT_MISSING",
severity: "failure",
message: `${sourceLabel}物理第 ${pageNumber} 页缺少${slot.alignment}页眉文本`,
...location,
details: {
source,
alignment: slot.alignment,
expected: slot.text,
},
});
}
return issues;
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
export function comparePdfPageSemantics(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
expectation: VisualPageSemanticExpectation,
candidateExpectation: VisualPageSemanticExpectation = expectation,
): PdfPageSemanticComparison {
const baselinePage = baseline.pages[expectation.physicalPageNumber - 1];
const candidatePage =
candidate.pages[candidateExpectation.physicalPageNumber - 1];
const baselineObservation = baselinePage
? observePageDecorations(baselinePage, expectation)
: undefined;
const candidateObservation = candidatePage
? observePageDecorations(candidatePage, candidateExpectation)
: undefined;
const issues = [
...(baselineObservation
? compareObservation(
"baseline",
expectation.physicalPageNumber,
expectation,
baselineObservation,
)
: []),
...(candidateObservation
? compareObservation(
"candidate",
candidateExpectation.physicalPageNumber,
candidateExpectation,
candidateObservation,
)
: []),
];
if (
expectation.headerVisible &&
candidateExpectation.headerVisible &&
baselineObservation?.headerBaselineY !== undefined &&
candidateObservation?.headerBaselineY !== undefined
) {
const deltaPt = Math.abs(
baselineObservation.headerBaselineY -
candidateObservation.headerBaselineY
);
if (deltaPt > DECORATION_VERTICAL_TOLERANCE_PT) {
issues.push({
code: "HEADER_VERTICAL_POSITION_MISMATCH",
severity: "failure",
message: `物理第 ${expectation.physicalPageNumber} 页页眉纵向位置偏差 ${deltaPt.toFixed(2)}pt 超过 ${DECORATION_VERTICAL_TOLERANCE_PT}pt`,
baselinePageNumber: expectation.physicalPageNumber,
candidatePageNumber: expectation.physicalPageNumber,
details: {
deltaPt,
tolerancePt: DECORATION_VERTICAL_TOLERANCE_PT,
baselineY: baselineObservation.headerBaselineY,
candidateY: candidateObservation.headerBaselineY,
},
});
}
}
if (
expectation.footerVisible &&
candidateExpectation.footerVisible &&
baselineObservation?.pageNumberBaselineY !== undefined &&
candidateObservation?.pageNumberBaselineY !== undefined
) {
const baselineMedianY = pageNumberBaselineMedian(baseline);
const candidateMedianY = pageNumberBaselineMedian(candidate);
const documentAnchorDeltaPt =
baselineMedianY === undefined || candidateMedianY === undefined
? Math.abs(
baselineObservation.pageNumberBaselineY -
candidateObservation.pageNumberBaselineY,
)
: Math.abs(baselineMedianY - candidateMedianY);
const pageRelativeDeltaPt =
baselineMedianY === undefined || candidateMedianY === undefined
? 0
: Math.abs(
baselineObservation.pageNumberBaselineY - baselineMedianY -
(candidateObservation.pageNumberBaselineY - candidateMedianY),
);
const deltaPt = Math.max(documentAnchorDeltaPt, pageRelativeDeltaPt);
if (deltaPt > DECORATION_VERTICAL_TOLERANCE_PT) {
issues.push({
code: "PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
severity: "failure",
message: `物理第 ${expectation.physicalPageNumber} 页页码纵向位置偏差 ${deltaPt.toFixed(2)}pt 超过 ${DECORATION_VERTICAL_TOLERANCE_PT}pt`,
baselinePageNumber: expectation.physicalPageNumber,
candidatePageNumber: expectation.physicalPageNumber,
details: {
deltaPt,
tolerancePt: DECORATION_VERTICAL_TOLERANCE_PT,
baselineY: baselineObservation.pageNumberBaselineY,
candidateY: candidateObservation.pageNumberBaselineY,
baselineMedianY: baselineMedianY ?? -1,
candidateMedianY: candidateMedianY ?? -1,
documentAnchorDeltaPt,
pageRelativeDeltaPt,
},
});
}
}
return {
expectation,
...(candidateExpectation === expectation
? {}
: { candidateExpectation }),
...(baselineObservation ? { baseline: baselineObservation } : {}),
...(candidateObservation ? { candidate: candidateObservation } : {}),
status: status(issues),
issues,
};
}
@@ -0,0 +1,405 @@
import {
buildPdfEditableContentLines,
normalizePdfEditableText,
} from "./text.js";
import type {
EditableParagraphExpectation,
PdfDocumentSnapshot,
PdfEditableContentLine,
PdfParagraphLayoutComparison,
PdfParagraphLayoutObservation,
VisualDiffIssue,
VisualPageSemanticExpectation,
} from "./types.js";
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
const LINE_X_TOLERANCE_PT = 2;
const COVER_LINE_X_TOLERANCE_PT = 12;
const LINE_HEIGHT_TOLERANCE_PT = 1;
const BASELINE_GAP_TOLERANCE_PT = 1.5;
const COVER_VERTICAL_TOLERANCE_PT = 6;
const BODY_NEAR_BOUNDARY_MIN_CHARACTERS = 30;
const BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO = 0.05;
interface MatchCursor {
lineIndex: number;
characterIndex: number;
}
function average(values: readonly number[]): number | undefined {
if (values.length === 0) {
return undefined;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function advanceCursor(
cursor: MatchCursor,
lineCharacters: readonly string[][],
): boolean {
cursor.characterIndex += 1;
while (
cursor.lineIndex < lineCharacters.length &&
cursor.characterIndex >= (lineCharacters[cursor.lineIndex]?.length ?? 0)
) {
cursor.lineIndex += 1;
cursor.characterIndex = 0;
}
return cursor.lineIndex < lineCharacters.length;
}
function observeParagraph(
expectation: EditableParagraphExpectation,
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
cursor: MatchCursor,
): PdfParagraphLayoutObservation {
const localCursor: MatchCursor = { ...cursor };
const expectedCharacters = Array.from(
normalizePdfEditableText(expectation.text),
);
const matchedByLine = new Map<number, string[]>();
let matchedCharacterCount = 0;
for (const expectedCharacter of expectedCharacters) {
let found = false;
while (localCursor.lineIndex < lineCharacters.length) {
const actualCharacter =
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
if (actualCharacter === expectedCharacter) {
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
matches.push(expectedCharacter);
matchedByLine.set(localCursor.lineIndex, matches);
matchedCharacterCount += 1;
advanceCursor(localCursor, lineCharacters);
found = true;
break;
}
if (!advanceCursor(localCursor, lineCharacters)) {
break;
}
}
if (!found) {
break;
}
}
const matchedLineIndexes = [...matchedByLine.keys()];
const matchedLines = matchedLineIndexes.flatMap((index) =>
lines[index] ? [lines[index]] : [],
);
const lineTexts = matchedLineIndexes.map((index) =>
(matchedByLine.get(index) ?? []).join(""),
);
let cumulativeCharacters = 0;
const lineBreakOffsets = lineTexts.slice(0, -1).map((text) => {
cumulativeCharacters += Array.from(text).length;
return cumulativeCharacters;
});
const baselineGaps = matchedLines.slice(1).flatMap((line, index) => {
const previous = matchedLines[index];
return previous && previous.pageNumber === line.pageNumber
? [line.line.baselineY - previous.line.baselineY]
: [];
});
const pageNumbers = [...new Set(matchedLines.map((line) => line.pageNumber))];
const firstLine = matchedLines[0];
const averageLineHeightPt = average(
matchedLines.map((line) => line.line.bounds.height),
);
const averageBaselineGapPt = average(baselineGaps);
const exclusiveGeometry = matchedLineIndexes.every(
(index) =>
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const matched = matchedCharacterCount === expectedCharacters.length;
if (matched) {
cursor.lineIndex = localCursor.lineIndex;
cursor.characterIndex = localCursor.characterIndex;
}
return {
matched,
matchedCharacterCount,
expectedCharacterCount: expectedCharacters.length,
pageNumbers,
lineCount: matchedLines.length,
lineTexts,
lineBreakOffsets,
...(firstLine
? {
firstLineXPt: firstLine.line.bounds.x,
firstLineBaselineYPt: firstLine.line.baselineY,
}
: {}),
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
? {}
: {
maximumLineWidthPt: Math.max(
...matchedLines.map((line) => line.line.bounds.width),
),
}),
...(averageLineHeightPt === undefined
? {}
: { averageLineHeightPt }),
...(averageBaselineGapPt === undefined
? {}
: { averageBaselineGapPt }),
exclusiveGeometry,
};
}
function delta(
baseline: number | undefined,
candidate: number | undefined,
): number | undefined {
return baseline === undefined || candidate === undefined
? undefined
: Math.abs(baseline - candidate);
}
function trailingLineCharacterCount(
observation: PdfParagraphLayoutObservation,
): number {
return Array.from(observation.lineTexts.at(-1) ?? "").length;
}
function isBodyNearBoundaryLineCountDifference(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
): boolean {
if (
expectation.section !== "body" ||
expectation.role !== "body" ||
baseline.expectedCharacterCount < BODY_NEAR_BOUNDARY_MIN_CHARACTERS ||
Math.abs(baseline.lineCount - candidate.lineCount) !== 1
) {
return false;
}
const moreLines =
baseline.lineCount > candidate.lineCount ? baseline : candidate;
const trailingCharacters = trailingLineCharacterCount(moreLines);
return (
trailingCharacters > 0 &&
trailingCharacters / baseline.expectedCharacterCount <=
BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO
);
}
function compareParagraph(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
): VisualDiffIssue[] {
const issues: VisualDiffIssue[] = [];
if (!baseline.matched || !candidate.matched) {
issues.push({
code: "EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个可编辑段落无法完整映射到 PDF 文本行`,
details: {
paragraphIndex: expectation.index,
baselineCoverage:
baseline.expectedCharacterCount === 0
? 1
: baseline.matchedCharacterCount / baseline.expectedCharacterCount,
candidateCoverage:
candidate.expectedCharacterCount === 0
? 1
: candidate.matchedCharacterCount / candidate.expectedCharacterCount,
},
});
return issues;
}
if (baseline.lineCount !== candidate.lineCount) {
const nearBoundary = isBodyNearBoundaryLineCountDifference(
expectation,
baseline,
candidate,
);
issues.push({
code: nearBoundary
? "BODY_LINE_COUNT_NEAR_BOUNDARY"
: "TEXT_BLOCK_LINE_COUNT_MISMATCH",
severity: nearBoundary ? "warning" : "failure",
message: nearBoundary
? `${expectation.index + 1} 个正文段落仅因末行边界产生一行差异`
: `${expectation.index + 1}${expectation.role === "title" ? "标题" : "段落"}行数不一致:基线 ${baseline.lineCount} 行,候选 ${candidate.lineCount}`,
details: {
paragraphIndex: expectation.index,
role: expectation.role,
baselineLineCount: baseline.lineCount,
candidateLineCount: candidate.lineCount,
...(nearBoundary
? {
trailingCharacters: trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
),
trailingRatio:
trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
) / baseline.expectedCharacterCount,
}
: {}),
},
});
} else {
const maximumBreakDelta = Math.max(
0,
...baseline.lineBreakOffsets.map((offset, index) =>
Math.abs(offset - (candidate.lineBreakOffsets[index] ?? offset)),
),
);
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
issues.push({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
details: {
paragraphIndex: expectation.index,
maximumBreakDelta,
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
baselineBreaks: baseline.lineBreakOffsets.join(","),
candidateBreaks: candidate.lineBreakOffsets.join(","),
},
});
}
}
if (expectation.section === "cover") {
const baselineOnCover =
baseline.pageNumbers.length === 1 && baseline.pageNumbers[0] === 1;
const candidateOnCover =
candidate.pageNumbers.length === 1 && candidate.pageNumbers[0] === 1;
const verticalDelta = delta(
baseline.firstLineBaselineYPt,
candidate.firstLineBaselineYPt,
);
if (
!baselineOnCover ||
!candidateOnCover ||
(verticalDelta ?? 0) > COVER_VERTICAL_TOLERANCE_PT
) {
issues.push({
code: "COVER_LAYOUT_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个封面段落未保持独立封面页或纵向位置`,
details: {
paragraphIndex: expectation.index,
baselinePages: baseline.pageNumbers.join(","),
candidatePages: candidate.pageNumbers.join(","),
verticalDeltaPt: verticalDelta ?? -1,
tolerancePt: COVER_VERTICAL_TOLERANCE_PT,
},
});
}
}
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
const xDelta = delta(baseline.firstLineXPt, candidate.firstLineXPt);
const xTolerancePt =
expectation.section === "cover"
? COVER_LINE_X_TOLERANCE_PT
: LINE_X_TOLERANCE_PT;
const widthDelta = delta(
baseline.maximumLineWidthPt,
candidate.maximumLineWidthPt,
);
const heightDelta = delta(
baseline.averageLineHeightPt,
candidate.averageLineHeightPt,
);
const baselineGapDelta = delta(
baseline.averageBaselineGapPt,
candidate.averageBaselineGapPt,
);
if (
(xDelta ?? 0) > xTolerancePt ||
(heightDelta ?? 0) > LINE_HEIGHT_TOLERANCE_PT ||
(baselineGapDelta ?? 0) > BASELINE_GAP_TOLERANCE_PT
) {
issues.push({
code: "PARAGRAPH_LINE_GEOMETRY_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行几何偏差超限`,
details: {
paragraphIndex: expectation.index,
xDeltaPt: xDelta ?? 0,
xTolerancePt,
widthDeltaPt: widthDelta ?? 0,
widthDeltaDiagnosticOnly: true,
lineHeightDeltaPt: heightDelta ?? 0,
baselineGapDeltaPt: baselineGapDelta ?? 0,
},
});
}
}
return issues;
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
export function comparePdfEditableParagraphLayouts(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
expectations: readonly EditableParagraphExpectation[],
baselinePageSemantics: readonly VisualPageSemanticExpectation[] = [],
candidatePageSemantics: readonly VisualPageSemanticExpectation[] =
baselinePageSemantics,
): PdfParagraphLayoutComparison[] {
const baselineLines = buildPdfEditableContentLines(
baseline,
baselinePageSemantics,
);
const candidateLines = buildPdfEditableContentLines(
candidate,
candidatePageSemantics,
);
const baselineCharacters = baselineLines.map((line) =>
Array.from(line.normalizedText),
);
const candidateCharacters = candidateLines.map((line) =>
Array.from(line.normalizedText),
);
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
return expectations.map((expectation) => {
const baselineObservation = observeParagraph(
expectation,
baselineLines,
baselineCharacters,
baselineCursor,
);
const candidateObservation = observeParagraph(
expectation,
candidateLines,
candidateCharacters,
candidateCursor,
);
const issues = compareParagraph(
expectation,
baselineObservation,
candidateObservation,
);
return {
expectation,
baseline: baselineObservation,
candidate: candidateObservation,
status: status(issues),
issues,
};
});
}
+47 -12
View File
@@ -4,6 +4,7 @@ import { createCanvas, loadImage } from "@napi-rs/canvas";
import type {
PdfPageRaster,
ComparePageRasterOptions,
PdfRasterDiffArtifacts,
PdfRasterDiffMetrics,
PdfRasterDiffResult,
@@ -29,7 +30,7 @@ async function decodeRaster(
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0);
context.drawImage(image, 0, 0, width, height);
return {
width,
height,
@@ -107,8 +108,6 @@ function encodeRgbaPng(
}
function createArtifacts(
baseline: PdfPageRaster,
candidate: PdfPageRaster,
baselineGray: Uint8Array,
candidateGray: Uint8Array,
pixelDifference: Uint8Array,
@@ -142,23 +141,48 @@ function createArtifacts(
}
const overlayPng = encodeRgbaPng(overlay, width, height);
const heatmapPng = encodeRgbaPng(heatmap, width, height);
const baselinePng = encodeGrayscalePng(baselineGray, width, height);
const candidatePng = encodeGrayscalePng(candidateGray, width, height);
return {
baselinePng: baseline.png,
candidatePng: candidate.png,
baselinePng,
candidatePng,
overlayPng,
heatmapPng,
baselineSha256: baseline.sha256,
candidateSha256: candidate.sha256,
baselineSha256: sha256(baselinePng),
candidateSha256: sha256(candidatePng),
overlaySha256: sha256(overlayPng),
heatmapSha256: sha256(heatmapPng),
};
}
function encodeGrayscalePng(
grayscale: Uint8Array,
width: number,
height: number,
): Uint8Array {
const rgba = new Uint8ClampedArray(width * height * 4);
for (let index = 0; index < grayscale.length; index += 1) {
const value = grayscale[index] ?? 255;
const offset = index * 4;
rgba[offset] = value;
rgba[offset + 1] = value;
rgba[offset + 2] = value;
rgba[offset + 3] = 255;
}
return encodeRgbaPng(rgba, width, height);
}
export async function comparePageRasters(
baseline: PdfPageRaster,
candidate: PdfPageRaster,
pixelDifferenceThreshold = 8,
options: number | ComparePageRasterOptions = {},
): Promise<PdfRasterDiffResult> {
const resolvedOptions =
typeof options === "number"
? { pixelDifferenceThreshold: options }
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -166,8 +190,16 @@ export async function comparePageRasters(
) {
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
}
const width = Math.max(baseline.widthPx, candidate.widthPx);
const height = Math.max(baseline.heightPx, candidate.heightPx);
const width =
resolvedOptions.targetWidthPx ??
Math.max(baseline.widthPx, candidate.widthPx);
const height =
resolvedOptions.targetHeightPx ??
Math.max(baseline.heightPx, candidate.heightPx);
if (!Number.isSafeInteger(width) || width <= 0 ||
!Number.isSafeInteger(height) || height <= 0) {
throw new Error("栅格比较尺寸必须是正整数");
}
const [baselineDecoded, candidateDecoded] = await Promise.all([
decodeRaster(baseline, width, height),
decodeRaster(candidate, width, height),
@@ -230,11 +262,16 @@ export async function comparePageRasters(
40,
);
const metrics: PdfRasterDiffMetrics = {
baselineWidthPx: baseline.widthPx,
baselineHeightPx: baseline.heightPx,
candidateWidthPx: candidate.widthPx,
candidateHeightPx: candidate.heightPx,
comparedWidthPx: width,
comparedHeightPx: height,
dimensionsMatch:
baseline.widthPx === candidate.widthPx &&
baseline.heightPx === candidate.heightPx,
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
@@ -243,8 +280,6 @@ export async function comparePageRasters(
return {
metrics,
artifacts: createArtifacts(
baseline,
candidate,
baselineGray,
candidateGray,
pixelDifference,
+192 -11
View File
@@ -1,8 +1,10 @@
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
import { comparePageRasters } from "./raster.js";
import { comparePdfPageSemantics } from "./page-semantics.js";
import type {
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
PdfPageDecorationObservation,
PdfPagePair,
PdfVisualDiffReport,
PdfVisualPageComparison,
@@ -29,13 +31,17 @@ function rasterIssues(
candidatePageNumber: pair.candidatePageNumber,
};
const issues: VisualDiffIssue[] = [];
if (!metrics.dimensionsMatch) {
if (!metrics.dimensionsMatch && !metrics.geometryNormalized) {
issues.push({
code: "RASTER_SIZE_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页栅格尺寸不一致`,
...location,
details: {
baselineWidthPx: metrics.baselineWidthPx,
baselineHeightPx: metrics.baselineHeightPx,
candidateWidthPx: metrics.candidateWidthPx,
candidateHeightPx: metrics.candidateHeightPx,
comparedWidthPx: metrics.comparedWidthPx,
comparedHeightPx: metrics.comparedHeightPx,
},
@@ -85,6 +91,7 @@ async function compareVisualPage(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholds: VisualDiffThresholds,
rasterComparisonMode: "strict" | "body-flow" = "strict",
): Promise<PdfVisualPageComparison> {
if (pair.status !== "paired") {
const page =
@@ -93,14 +100,27 @@ async function compareVisualPage(
: candidate.pages[pair.candidatePageNumber - 1];
return {
pair,
status: "failed",
rasterComparisonMode,
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
...(page?.raster
? {
unpairedPng: page.raster.png,
unpairedSha256: page.raster.sha256,
}
: {}),
issues: [],
issues:
rasterComparisonMode === "body-flow"
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE",
severity: "warning",
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
...(pair.status === "baseline-only"
? { baselinePageNumber: pair.baselinePageNumber }
: { candidatePageNumber: pair.candidatePageNumber }),
},
]
: [],
};
}
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
@@ -122,11 +142,48 @@ async function compareVisualPage(
const result = await comparePageRasters(
baselinePage.raster,
candidatePage.raster,
thresholds.pixelDifferenceThreshold,
{
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
thresholds.pageSizeDeltaPt &&
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
thresholds.pageSizeDeltaPt &&
baselinePage.rotation === candidatePage.rotation
? {
targetWidthPx: baselinePage.raster.widthPx,
targetHeightPx: baselinePage.raster.heightPx,
geometryNormalized:
baselinePage.raster.widthPx !== candidatePage.raster.widthPx ||
baselinePage.raster.heightPx !== candidatePage.raster.heightPx,
}
: {}),
},
);
const issues = rasterIssues(pair, result.metrics, thresholds);
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
const issues =
rasterComparisonMode === "body-flow" && strictIssues.length > 0
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
severity: "warning" as const,
message: `${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
details: {
strictIssueCodes: strictIssues
.map((issue) => issue.code)
.join(","),
meanAbsoluteError: result.metrics.meanAbsoluteError,
changedPixelRatio: result.metrics.changedPixelRatio,
inkIou: result.metrics.inkIou,
edgeIou: result.metrics.edgeIou,
},
},
]
: strictIssues;
return {
pair,
rasterComparisonMode,
status: resolveStatus(issues),
metrics: result.metrics,
artifacts: result.artifacts,
@@ -143,12 +200,71 @@ export async function createPdfVisualDiffReport(
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
...options.thresholds,
};
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds);
const baselinePageSemantics =
options.baselinePageSemantics ?? options.pageSemantics ?? [];
const candidatePageSemantics =
options.candidatePageSemantics ?? options.pageSemantics ?? [];
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds, {
...(options.expectedEditableText === undefined
? {}
: { expectedEditableText: options.expectedEditableText }),
...(baselinePageSemantics.length === 0
? {}
: { baselinePageSemantics }),
...(candidatePageSemantics.length === 0
? {}
: { candidatePageSemantics }),
...(options.expectedEditableParagraphs === undefined
? {}
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
});
const pages: PdfVisualPageComparison[] = [];
for (const pair of basic.pagePairs) {
pages.push(
await compareVisualPage(pair, baseline, candidate, thresholds),
const baselineSemanticExpectation =
pair.status === "candidate-only"
? undefined
: baselinePageSemantics.find(
(item) => item.physicalPageNumber === pair.baselinePageNumber,
);
const candidateSemanticExpectation =
pair.status === "baseline-only"
? undefined
: candidatePageSemantics.find(
(item) => item.physicalPageNumber === pair.candidatePageNumber,
);
const bodyFlow = basic.bodyFlow;
const flowAffected = Boolean(
bodyFlow?.legal &&
((pair.status !== "candidate-only" &&
bodyFlow.affectedBaselinePageNumbers.includes(
pair.baselinePageNumber,
)) ||
(pair.status !== "baseline-only" &&
bodyFlow.affectedCandidatePageNumbers.includes(
pair.candidatePageNumber,
))),
);
const page = await compareVisualPage(
pair,
baseline,
candidate,
thresholds,
flowAffected ? "body-flow" : "strict",
);
const primarySemanticExpectation =
baselineSemanticExpectation ?? candidateSemanticExpectation;
if (primarySemanticExpectation) {
const semantics = comparePdfPageSemantics(
baseline,
candidate,
primarySemanticExpectation,
candidateSemanticExpectation ?? primarySemanticExpectation,
);
page.semantics = semantics;
page.issues.push(...semantics.issues);
page.status = resolveStatus(page.issues);
}
pages.push(page);
}
const issues = [
...basic.issues,
@@ -214,19 +330,80 @@ function renderArtifacts(page: PdfVisualPageComparison): string {
</div>`;
}
function renderSemanticObservation(
label: string,
observation: PdfPageDecorationObservation | undefined,
): string {
if (!observation) {
return `<td>${escapeHtml(label)}:无页面</td>`;
}
return `<td><strong>${escapeHtml(label)}</strong><br>页码:${escapeHtml(observation.pageNumberText ?? "无")}<br>位置:${escapeHtml(observation.pageNumberAlignment ?? "无")}<br>匹配页眉槽:${observation.matchedHeaderSlots.length}<br>缺失页眉槽:${observation.missingHeaderSlots.length}</td>`;
}
function renderSemantics(page: PdfVisualPageComparison): string {
const semantics = page.semantics;
if (!semantics) {
return "";
}
const expected = semantics.expectation;
const candidateExpected = semantics.candidateExpectation ?? expected;
const kindLabels = {
cover: "封面",
"body-first": "正文首页",
"body-rest": "正文后续页",
} as const;
return `<div class="semantics">
<h3>页面语义 <span class="status ${semantics.status}">${semantics.status}</span></h3>
<table><tbody>
<tr><th>基线预期</th><td>物理第 ${expected.physicalPageNumber} 页;${kindLabels[expected.kind]};逻辑页 ${expected.logicalPageNumber ?? "—"} / ${expected.logicalPageCount};页眉 ${expected.headerVisible ? "显示" : "隐藏"};页码 ${expected.footerVisible ? `${escapeHtml(expected.pageNumberText ?? "显示")}${escapeHtml(expected.footerAlignment ?? "未指定位置")}` : "隐藏"}</td></tr>
<tr><th>候选预期</th><td>物理第 ${candidateExpected.physicalPageNumber} 页;${kindLabels[candidateExpected.kind]};逻辑页 ${candidateExpected.logicalPageNumber ?? "—"} / ${candidateExpected.logicalPageCount};页眉 ${candidateExpected.headerVisible ? "显示" : "隐藏"};页码 ${candidateExpected.footerVisible ? `${escapeHtml(candidateExpected.pageNumberText ?? "显示")}${escapeHtml(candidateExpected.footerAlignment ?? "未指定位置")}` : "隐藏"}</td></tr>
<tr><th>观测</th>${renderSemanticObservation("基线", semantics.baseline)}${renderSemanticObservation("候选", semantics.candidate)}</tr>
</tbody></table>
</div>`;
}
function pageTitle(page: PdfVisualPageComparison): string {
const mode =
page.rasterComparisonMode === "body-flow"
? "(正文流动比较)"
: "(严格比较)";
if (page.pair.status === "paired") {
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}`;
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}${mode}`;
}
if (page.pair.status === "baseline-only") {
return `基线第 ${page.pair.baselinePageNumber} 页无配对`;
return `基线第 ${page.pair.baselinePageNumber} 页无配对${mode}`;
}
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`;
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
}
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
const paragraphs = report.basic.paragraphLayouts;
if (!paragraphs) {
return "";
}
const failures = paragraphs.filter(
(paragraph) => paragraph.status === "failed",
);
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
.map((paragraph) => {
const expectation = paragraph.expectation;
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
})
.join("");
return `<div class="paragraph-layouts">
<h2>内容感知行版式 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
<p>段落 ${paragraphs.length} 个,失败 ${failures.length} 个;正文跨页移动不参与失败判定,封面页位置严格校验。</p>
<table><thead><tr><th>段落</th><th>分节</th><th>角色</th><th>Word 样式</th><th>基线行数</th><th>候选行数</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
export function renderPdfVisualDiffHtml(
report: PdfVisualDiffReport,
): string {
const editableMetrics =
report.basic.baselineEditableCoverage === undefined
? ""
: `<p><strong>Chromium 可编辑正文覆盖:</strong>${percentage(report.basic.baselineEditableCoverage)} <strong>候选可编辑正文相似度:</strong>${percentage(report.basic.candidateEditableSimilarity ?? 0)} <strong>候选精确匹配:</strong>${report.basic.candidateEditableExact ? "是" : "否"}</p>`;
const issueRows = report.issues.length
? report.issues
.map(
@@ -239,6 +416,7 @@ export function renderPdfVisualDiffHtml(
.map(
(page, index) => `<section class="page-card">
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
${renderSemantics(page)}
${renderMetrics(page)}
${renderArtifacts(page)}
</section>`,
@@ -257,6 +435,7 @@ export function renderPdfVisualDiffHtml(
.status{display:inline-block;border-radius:999px;padding:4px 10px;font-size:12px;text-transform:uppercase}
.passed{background:#dff7e8;color:#126636}.warning{background:#fff1c7;color:#805d00}.failed{background:#ffe0e0;color:#9a1b1b}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin-bottom:16px}
.semantics{margin:0 0 16px;padding:14px;background:#f7f9fc;border-radius:8px}.semantics h3{margin:0 0 10px;font-size:15px}.semantics th{width:90px}
.metrics div{padding:12px;background:#f7f9fc;border-radius:8px}.metrics span{display:block;color:#667085;font-size:12px}.metrics strong{font-size:20px}
.side-by-side{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.unpaired{max-width:720px;margin-top:16px}
figure{margin:0;min-width:0}figcaption{margin-bottom:8px;color:#475467;font-weight:600}
@@ -270,6 +449,8 @@ export function renderPdfVisualDiffHtml(
<h1>PDF 视觉差异报告 <span class="status ${report.status}">${report.status}</span></h1>
<p><strong>基线:</strong>${escapeHtml(report.basic.baseline.label)} <strong>候选:</strong>${escapeHtml(report.basic.candidate.label)}</p>
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
${editableMetrics}
${renderParagraphLayouts(report)}
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}
+74
View File
@@ -1,10 +1,17 @@
import type {
PdfDocumentSnapshot,
PdfEditableContentLine,
PdfPointBounds,
PdfTextItemSnapshot,
PdfTextLineSnapshot,
VisualPageSemanticExpectation,
} from "./types.js";
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
const CJK_RADICAL_VARIANTS = new Map([
["⺠", "民"],
["⻔", "门"],
]);
const PAGE_NUMBER_PATTERNS = [
/^(?:[-]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-])?$/u,
/^\s*\d+\s*(?:\s*(?:[/]|)\s*\d+\s*)?$/u,
@@ -14,6 +21,10 @@ const PAGE_NUMBER_PATTERNS = [
export function normalizePdfText(value: string): string {
return value
.normalize("NFKC")
.replace(//gu, "")
.replace(/[\u2e80-\u2eff]/gu, (character) =>
CJK_RADICAL_VARIANTS.get(character) ?? character
)
.replace(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
@@ -24,6 +35,69 @@ export function normalizePdfContentText(value: string): string {
return normalizePdfText(value).replace(/\s+/gu, "");
}
export function normalizePdfEditableText(value: string): string {
return normalizePdfContentText(value).replace(/[\uf0b7]/gu, "");
}
function isExpectedHeaderLine(
line: PdfTextLineSnapshot,
pageHeightPt: number,
expectation: VisualPageSemanticExpectation | undefined,
): boolean {
if (
!expectation?.headerVisible ||
line.bounds.y + line.bounds.height / 2 > pageHeightPt * 0.12
) {
return false;
}
const lineText = normalizePdfEditableText(line.normalizedText);
return (expectation.headerSlots ?? []).some((slot) => {
const slotText = normalizePdfEditableText(slot.text);
return slotText.length > 0 && lineText.includes(slotText);
});
}
function isInternalLayoutSpacerLine(line: PdfTextLineSnapshot): boolean {
return (
normalizePdfEditableText(line.normalizedText) === "." &&
line.bounds.height <= 1.5 &&
line.items.length === 1
);
}
export function buildPdfEditableContentText(
snapshot: PdfDocumentSnapshot,
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
): string {
return buildPdfEditableContentLines(snapshot, pageSemantics)
.map((item) => item.normalizedText)
.join("");
}
export function buildPdfEditableContentLines(
snapshot: PdfDocumentSnapshot,
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
): PdfEditableContentLine[] {
return snapshot.pages.flatMap((page) => {
const expectation = pageSemantics.find(
(item) => item.physicalPageNumber === page.pageNumber,
);
return page.lines.flatMap((line) => {
if (
line.role !== "content" ||
isExpectedHeaderLine(line, page.heightPt, expectation) ||
isInternalLayoutSpacerLine(line)
) {
return [];
}
const normalizedText = normalizePdfEditableText(line.normalizedText);
return normalizedText
? [{ pageNumber: page.pageNumber, line, normalizedText }]
: [];
});
});
}
function unionBounds(items: readonly PdfTextItemSnapshot[]): PdfPointBounds {
const left = Math.min(...items.map((item) => item.bounds.x));
const top = Math.min(...items.map((item) => item.bounds.y));
+144 -1
View File
@@ -33,6 +33,63 @@ export interface PdfTextLineSnapshot {
items: PdfTextItemSnapshot[];
}
export interface PdfEditableContentLine {
pageNumber: number;
line: PdfTextLineSnapshot;
normalizedText: string;
}
export type EditableParagraphRole =
| "body"
| "heading"
| "title"
| "caption"
| "other";
export interface EditableParagraphExpectation {
index: number;
text: string;
styleId?: string;
role: EditableParagraphRole;
section: "cover" | "body";
}
export interface PdfParagraphLayoutObservation {
matched: boolean;
matchedCharacterCount: number;
expectedCharacterCount: number;
pageNumbers: number[];
lineCount: number;
lineTexts: string[];
lineBreakOffsets: number[];
firstLineXPt?: number;
firstLineBaselineYPt?: number;
maximumLineWidthPt?: number;
averageLineHeightPt?: number;
averageBaselineGapPt?: number;
exclusiveGeometry: boolean;
}
export interface PdfParagraphLayoutComparison {
expectation: EditableParagraphExpectation;
baseline: PdfParagraphLayoutObservation;
candidate: PdfParagraphLayoutObservation;
status: "passed" | "warning" | "failed";
issues: VisualDiffIssue[];
}
export interface PdfBodyFlowComparison {
detected: boolean;
legal: boolean;
baselineCoverPageCount: number;
candidateCoverPageCount: number;
baselineBodyPageCount: number;
candidateBodyPageCount: number;
movedParagraphIndexes: number[];
affectedBaselinePageNumbers: number[];
affectedCandidatePageNumbers: number[];
}
export interface PdfPageRaster {
widthPx: number;
heightPx: number;
@@ -101,7 +158,22 @@ export type VisualDiffIssueCode =
| "PIXEL_MAE_EXCEEDED"
| "CHANGED_PIXEL_RATIO_EXCEEDED"
| "INK_IOU_BELOW_THRESHOLD"
| "EDGE_IOU_BELOW_THRESHOLD";
| "EDGE_IOU_BELOW_THRESHOLD"
| "PAGE_NUMBER_VISIBILITY_MISMATCH"
| "PAGE_NUMBER_TEXT_MISMATCH"
| "PAGE_NUMBER_ALIGNMENT_MISMATCH"
| "PAGE_NUMBER_VERTICAL_POSITION_MISMATCH"
| "HEADER_VISIBILITY_MISMATCH"
| "HEADER_TEXT_MISSING"
| "HEADER_VERTICAL_POSITION_MISMATCH"
| "EDITABLE_PARAGRAPH_MAPPING_MISMATCH"
| "TEXT_BLOCK_LINE_COUNT_MISMATCH"
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
| "PARAGRAPH_LINE_BREAK_MISMATCH"
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
| "COVER_LAYOUT_MISMATCH"
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
| "BODY_FLOW_RASTER_DIFFERENCE";
export interface VisualDiffIssue {
code: VisualDiffIssueCode;
@@ -134,20 +206,37 @@ export interface PdfBasicComparison {
candidate: PdfSnapshotSource;
thresholds: VisualDiffThresholds;
contentSimilarity: number;
baselineEditableCoverage?: number;
candidateEditableSimilarity?: number;
candidateEditableExact?: boolean;
paragraphLayouts?: PdfParagraphLayoutComparison[];
bodyFlow?: PdfBodyFlowComparison;
pagePairs: PdfPagePair[];
issues: VisualDiffIssue[];
}
export interface PdfRasterDiffMetrics {
baselineWidthPx: number;
baselineHeightPx: number;
candidateWidthPx: number;
candidateHeightPx: number;
comparedWidthPx: number;
comparedHeightPx: number;
dimensionsMatch: boolean;
geometryNormalized: boolean;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
edgeIou: number;
}
export interface ComparePageRasterOptions {
pixelDifferenceThreshold?: number;
targetWidthPx?: number;
targetHeightPx?: number;
geometryNormalized?: boolean;
}
export interface PdfRasterDiffArtifacts {
baselinePng: Uint8Array;
candidatePng: Uint8Array;
@@ -166,11 +255,52 @@ export interface PdfRasterDiffResult {
export interface PdfVisualPageComparison {
pair: PdfPagePair;
rasterComparisonMode?: "strict" | "body-flow";
status: "passed" | "warning" | "failed";
metrics?: PdfRasterDiffMetrics;
artifacts?: PdfRasterDiffArtifacts;
unpairedPng?: Uint8Array;
unpairedSha256?: string;
semantics?: PdfPageSemanticComparison;
issues: VisualDiffIssue[];
}
export type VisualPageKind = "cover" | "body-first" | "body-rest";
export type VisualPageAlignment = "left" | "center" | "right";
export interface VisualHeaderSlotExpectation {
alignment: VisualPageAlignment;
text: string;
}
export interface VisualPageSemanticExpectation {
physicalPageNumber: number;
kind: VisualPageKind;
logicalPageNumber?: number;
logicalPageCount: number;
headerVisible: boolean;
headerSlots?: VisualHeaderSlotExpectation[];
footerVisible: boolean;
footerAlignment?: VisualPageAlignment;
pageNumberText?: string;
}
export interface PdfPageDecorationObservation {
pageNumberText?: string;
pageNumberAlignment?: VisualPageAlignment;
pageNumberBaselineY?: number;
headerText?: string;
headerBaselineY?: number;
matchedHeaderSlots: VisualHeaderSlotExpectation[];
missingHeaderSlots: VisualHeaderSlotExpectation[];
}
export interface PdfPageSemanticComparison {
expectation: VisualPageSemanticExpectation;
candidateExpectation?: VisualPageSemanticExpectation;
baseline?: PdfPageDecorationObservation;
candidate?: PdfPageDecorationObservation;
status: "passed" | "warning" | "failed";
issues: VisualDiffIssue[];
}
@@ -186,4 +316,17 @@ export interface PdfVisualDiffReport {
export interface CreatePdfVisualDiffOptions {
thresholds?: Partial<VisualDiffThresholds>;
generatedAt?: string;
pageSemantics?: readonly VisualPageSemanticExpectation[];
baselinePageSemantics?: readonly VisualPageSemanticExpectation[];
candidatePageSemantics?: readonly VisualPageSemanticExpectation[];
expectedEditableText?: string;
expectedEditableParagraphs?: readonly EditableParagraphExpectation[];
}
export interface PdfEditableContentComparisonOptions {
expectedEditableText?: string;
pageSemantics?: readonly VisualPageSemanticExpectation[];
baselinePageSemantics?: readonly VisualPageSemanticExpectation[];
candidatePageSemantics?: readonly VisualPageSemanticExpectation[];
expectedEditableParagraphs?: readonly EditableParagraphExpectation[];
}
@@ -6,7 +6,9 @@ import { describe, expect, it, vi } from "vitest";
import {
createChromiumPdfAdapter,
createWordDocxRoundTripAdapter,
createWordPdfAdapter,
createWpsDocxRoundTripAdapter,
createWpsPdfAdapter,
preparePdfAdapterRun,
runPdfAdapterVisualDiff,
@@ -58,6 +60,7 @@ describe("Office PDF 适配器", () => {
progId: string;
outputPath: string;
}> = [];
const roundTripCalls: string[] = [];
const backend: OfficeAutomationBackend = {
probe: async () => true,
exportPdf: async (options) => {
@@ -70,6 +73,22 @@ describe("Office PDF 适配器", () => {
});
return { bytes: pdf.byteLength, pageCount: 1 };
},
roundTripDocx: async (options) => {
const docx = Buffer.from("round-trip-docx");
await writeFile(options.outputPath, docx);
roundTripCalls.push(options.client);
return {
bytes: docx.byteLength,
before: [
{ index: 1, widthPt: 100, heightPt: 50, type: 3 },
],
saved: [
{ index: 1, widthPt: 90, heightPt: 45, type: 3 },
],
resizedShapeIndex: 1,
resizeScale: options.resizeScale,
};
},
};
try {
const word = createWordPdfAdapter({ backend });
@@ -78,6 +97,15 @@ describe("Office PDF 适配器", () => {
expect((await wps.probe()).available).toBe(true);
expect((await word.generate({ docxPath })).source.kind).toBe("word");
expect((await wps.generate({ docxPath })).source.kind).toBe("wps");
const wordRoundTrip = await createWordDocxRoundTripAdapter({
backend,
}).generate({ docxPath });
const wpsRoundTrip = await createWpsDocxRoundTripAdapter({
backend,
}).generate({ docxPath });
expect(wordRoundTrip.saved[0]?.widthPt).toBe(90);
expect(wpsRoundTrip.saved[0]?.heightPt).toBe(45);
expect(roundTripCalls).toEqual(["word", "wps"]);
expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([
{ client: "word", progId: "Word.Application" },
{ client: "wps", progId: "KWPS.Application" },
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
calculateContentSimilarity,
comparePdfSnapshotsBasic,
createPdfDocumentSnapshot,
type PdfDocumentSnapshot,
type PdfTextLineSnapshot,
type VisualPageSemanticExpectation,
} from "../src/index.js";
import { createMinimalPdf } from "./pdf-fixture.js";
@@ -19,7 +22,105 @@ async function snapshot(
});
}
function textLine(
text: string,
y: number,
role: PdfTextLineSnapshot["role"] = "content",
): PdfTextLineSnapshot {
return {
text,
normalizedText: text,
bounds: { x: 72, y, width: 200, height: 12 },
baselineY: y + 10,
role,
items: [],
};
}
function withLines(
source: PdfDocumentSnapshot,
lines: PdfTextLineSnapshot[],
): PdfDocumentSnapshot {
const page = source.pages[0];
if (!page) {
throw new Error("测试快照缺少页面");
}
return {
...source,
contentText: lines.map((line) => line.normalizedText).join("\n"),
pages: [{ ...page, lines }],
};
}
const pageSemantics: VisualPageSemanticExpectation[] = [
{
physicalPageNumber: 1,
kind: "body-first",
logicalPageNumber: 1,
logicalPageCount: 1,
headerVisible: true,
headerSlots: [{ alignment: "left", text: "页眉左栏" }],
footerVisible: true,
footerAlignment: "center",
pageNumberText: "1 / 1",
},
];
describe("PDF 基础视觉门禁", () => {
it("将 PDF 字体映射产生的等价部首字形规范为正文汉字", () => {
expect(
calculateContentSimilarity(
"示例市人⺠政府办公室",
"示例市人民政府办公室",
),
).toBe(1);
expect(calculateContentSimilarity("项目⻔户", "项目门户")).toBe(1);
});
it("以 DOCX 可编辑正文为语义基准并容许 Chromium 媒体内部文本", async () => {
const baseline = withLines(await snapshot("baseline"), [
textLine("页眉左栏", 10),
textLine("正文甲", 100),
textLine("媒体内部标签", 150),
textLine("正文乙", 200),
textLine("1 / 1", 820, "page-number"),
]);
const candidate = withLines(await snapshot("candidate"), [
textLine("页眉左栏", 10),
textLine("正文甲☒", 100),
textLine("正文乙", 200),
textLine("1 / 1", 820, "page-number"),
]);
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
expectedEditableText: "正文甲正文乙",
pageSemantics,
});
expect(result.status).toBe("passed");
expect(result.baselineEditableCoverage).toBe(1);
expect(result.candidateEditableSimilarity).toBe(1);
expect(result.candidateEditableExact).toBe(true);
});
it("候选缺少任一可编辑正文字符时严格失败", async () => {
const baseline = withLines(await snapshot("baseline"), [
textLine("正文甲正文乙", 100),
]);
const candidate = withLines(await snapshot("candidate"), [
textLine("正文甲正文", 100),
]);
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
expectedEditableText: "正文甲正文乙",
});
expect(result.status).toBe("failed");
expect(result.baselineEditableCoverage).toBe(1);
expect(result.candidateEditableExact).toBe(false);
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "CONTENT_MISMATCH" }),
]),
);
});
it("通过纸张和正文一致的文档", async () => {
const baseline = await snapshot("baseline");
const candidate = await snapshot("candidate");
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import {
comparePdfEditableParagraphLayouts,
type EditableParagraphExpectation,
type PdfDocumentSnapshot,
type PdfTextLineSnapshot,
} from "../src/index.js";
function line(
text: string,
y: number,
width = 120,
x = 72,
): PdfTextLineSnapshot {
return {
text,
normalizedText: text,
bounds: { x, y: y - 10, width, height: 12 },
baselineY: y,
role: "content",
items: [],
};
}
function snapshot(
label: string,
pages: PdfTextLineSnapshot[][],
): PdfDocumentSnapshot {
return {
schemaVersion: 1,
source: { kind: "custom", label },
sha256: label.padEnd(64, "0").slice(0, 64),
pageCount: pages.length,
contentText: pages.flat().map((item) => item.normalizedText).join(""),
pages: pages.map((lines, index) => ({
pageNumber: index + 1,
widthPt: 595,
heightPt: 842,
rotation: 0,
items: [],
lines,
contentText: lines.map((item) => item.normalizedText).join(""),
})),
};
}
function expectation(
text: string,
overrides: Partial<EditableParagraphExpectation> = {},
): EditableParagraphExpectation {
return {
index: 0,
text,
role: "body",
section: "body",
...overrides,
};
}
describe("PDF 内容感知段落版式门禁", () => {
it("允许正文段落整体移动到下一物理页", () => {
const baseline = snapshot("baseline", [
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
[],
]);
const candidate = snapshot("candidate", [
[],
[line("建立月度调度机制及时协调", 100), line("解决项目中的问题", 128)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation("建立月度调度机制及时协调解决项目中的问题")],
);
expect(result?.status).toBe("passed");
expect(result?.baseline.pageNumbers).toEqual([1]);
expect(result?.candidate.pageNumbers).toEqual([2]);
});
it("拒绝标题从一行变成两行", () => {
const baseline = snapshot("baseline", [
[line("智慧园区一体化平台建设项目", 220, 250)],
]);
const candidate = snapshot("candidate", [
[line("智慧园区一体化平台", 220, 190), line("建设项目", 250, 60)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("智慧园区一体化平台建设项目", {
role: "title",
section: "cover",
styleId: "MdProjectReportProjectName",
}),
],
);
expect(result?.status).toBe("failed");
expect(result?.issues.map((issue) => issue.code)).toContain(
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
);
});
it("允许长正文仅有极短末行的跨引擎换行差异", () => {
const text =
"数字化管理部门负责项目统筹技术审查过程监督和验收管理需求单位负责业务需求确认试运行和应用推广";
const baseline = snapshot("baseline", [
[line(text.slice(0, -2), 220, 680), line(text.slice(-2), 244, 28)],
]);
const candidate = snapshot("candidate", [[line(text, 220, 695)]]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation(text)],
);
expect(result?.status).toBe("warning");
expect(result?.issues).toEqual([
expect.objectContaining({
code: "BODY_LINE_COUNT_NEAR_BOUNDARY",
severity: "warning",
}),
]);
});
it("拒绝短正文或非极短末行的行数差异", () => {
const text = "项目建设内容需要严格控制质量进度投资安全风险";
const baseline = snapshot("baseline", [
[line(text.slice(0, -5), 220), line(text.slice(-5), 244)],
]);
const candidate = snapshot("candidate", [[line(text, 220)]]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation(text)],
);
expect(result?.status).toBe("failed");
expect(result?.issues.map((issue) => issue.code)).toContain(
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
);
});
it("拒绝封面内容漂移到正文页", () => {
const baseline = snapshot("baseline", [
[line("可行性研究报告", 300, 180)],
[],
]);
const candidate = snapshot("candidate", [
[],
[line("可行性研究报告", 100, 180)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("可行性研究报告", {
role: "title",
section: "cover",
styleId: "MdProjectReportTitle",
}),
],
);
expect(result?.status).toBe("failed");
expect(result?.issues.map((issue) => issue.code)).toContain(
"COVER_LAYOUT_MISMATCH",
);
});
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
const baseline = snapshot("baseline", [
[line("数据治理平台建设项目", 300, 224)],
]);
const candidate = snapshot("candidate", [
[line("数据治理平台建设项目", 300, 210)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("数据治理平台建设项目", {
role: "title",
section: "cover",
styleId: "MdProjectReportTitle",
}),
],
);
expect(result?.status).toBe("passed");
expect(result?.baseline.maximumLineWidthPt).toBe(224);
expect(result?.candidate.maximumLineWidthPt).toBe(210);
});
it("允许封面字形基线在 3pt 内波动", () => {
const baseline = snapshot("baseline", [
[line("数据治理平台建设项目", 300, 224)],
]);
const candidate = snapshot("candidate", [
[line("数据治理平台建设项目", 302.5, 224)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("数据治理平台建设项目", {
role: "title",
section: "cover",
styleId: "MdProjectReportTitle",
}),
],
);
expect(result?.status).toBe("passed");
});
it("拒绝封面字形基线偏移超过 6pt", () => {
const baseline = snapshot("baseline", [
[line("数据治理平台建设项目", 300, 224)],
]);
const candidate = snapshot("candidate", [
[line("数据治理平台建设项目", 306.1, 224)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("数据治理平台建设项目", {
role: "title",
section: "cover",
styleId: "MdProjectReportTitle",
}),
],
);
expect(result?.status).toBe("failed");
expect(result?.issues.map((issue) => issue.code)).toContain(
"COVER_LAYOUT_MISMATCH",
);
});
it("允许封面文本提取框横向偏差在 3pt 内波动", () => {
const baseline = snapshot("baseline", [
[line("数据治理平台建设项目", 300, 224, 198)],
]);
const candidate = snapshot("candidate", [
[line("数据治理平台建设项目", 300, 224, 200.5)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[
expectation("数据治理平台建设项目", {
role: "title",
section: "cover",
styleId: "MdProjectReportTitle",
}),
],
);
expect(result?.status).toBe("passed");
});
it("正文文本横向偏差仍严格限制为 2pt", () => {
const baseline = snapshot("baseline", [
[line("项目建设内容", 300, 120, 72)],
]);
const candidate = snapshot("candidate", [
[line("项目建设内容", 300, 120, 74.5)],
]);
const [result] = comparePdfEditableParagraphLayouts(
baseline,
candidate,
[expectation("项目建设内容")],
);
expect(result?.status).toBe("failed");
expect(result?.issues.map((issue) => issue.code)).toContain(
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
);
});
});
@@ -25,6 +25,23 @@ function raster(rectangleX: number): PdfPageRaster {
};
}
function differentlySizedRaster(width: number, height: number): PdfPageRaster {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.fillStyle = "#111111";
context.fillRect(width * 0.25, height * 0.2, width * 0.25, height * 0.4);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx: width,
heightPx: height,
dpi: 144,
sha256: createHash("sha256").update(png).digest("hex"),
png,
};
}
describe("PDF 栅格差异", () => {
it("相同页面的全部指标为无差异", async () => {
const page = raster(10);
@@ -50,4 +67,24 @@ describe("PDF 栅格差异", () => {
expect(result.artifacts.overlaySha256).toHaveLength(64);
expect(result.artifacts.heatmapSha256).toHaveLength(64);
});
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
const result = await comparePageRasters(
differentlySizedRaster(80, 100),
differentlySizedRaster(81, 101),
{
targetWidthPx: 80,
targetHeightPx: 100,
geometryNormalized: true,
},
);
expect(result.metrics).toMatchObject({
baselineWidthPx: 80,
candidateWidthPx: 81,
comparedWidthPx: 80,
dimensionsMatch: false,
geometryNormalized: true,
});
expect(result.metrics.meanAbsoluteError).toBeLessThan(2);
});
});
@@ -9,6 +9,8 @@ import {
serializePdfVisualDiffJson,
type PdfDocumentSnapshot,
type PdfPageRaster,
type PdfTextLineSnapshot,
type VisualPageSemanticExpectation,
} from "../src/index.js";
function raster(color: string): PdfPageRaster {
@@ -50,6 +52,57 @@ function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot
};
}
function contentLine(text: string, y: number): PdfTextLineSnapshot {
return {
text,
normalizedText: text,
bounds: { x: 4, y, width: 8, height: 2 },
baselineY: y + 2,
role: "content",
items: [],
};
}
function flowSnapshot(
label: string,
pageLines: readonly (readonly PdfTextLineSnapshot[])[],
color: string,
): PdfDocumentSnapshot {
return {
schemaVersion: 1,
source: { kind: "custom", label },
sha256: label.padEnd(64, "0").slice(0, 64),
pageCount: pageLines.length,
contentText: pageLines.flat().map((line) => line.normalizedText).join(""),
pages: pageLines.map((lines, index) => ({
pageNumber: index + 1,
widthPt: 20,
heightPt: 25,
rotation: 0,
items: [],
lines: [...lines],
contentText: lines.map((line) => line.normalizedText).join(""),
raster: raster(color),
})),
};
}
function bodySemantics(pageCount: number): VisualPageSemanticExpectation[] {
return Array.from({ length: pageCount }, (_, index) => ({
physicalPageNumber: index + 1,
kind: index === 0 ? "body-first" : "body-rest",
logicalPageNumber: index + 1,
logicalPageCount: pageCount,
headerVisible: false,
footerVisible: false,
}));
}
const flowParagraphs = [
{ index: 0, text: "甲", role: "body" as const, section: "body" as const },
{ index: 1, text: "乙丙", role: "body" as const, section: "body" as const },
];
describe("PDF 视觉差异报告", () => {
it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => {
const report = await createPdfVisualDiffReport(
@@ -97,4 +150,352 @@ describe("PDF 视觉差异报告", () => {
expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0);
expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面");
});
it("纸张物理尺寸容差内不因一像素舍入差重复失败", async () => {
const baseline = snapshot("基线", raster("#111111"));
const candidate = snapshot("候选", {
...raster("#111111"),
widthPx: 41,
});
candidate.pages[0]!.widthPt = baseline.pages[0]!.widthPt + 0.36;
const report = await createPdfVisualDiffReport(baseline, candidate, {
thresholds: {
maxMeanAbsoluteError: 255,
maxChangedPixelRatio: 1,
minInkIou: 0,
minEdgeIou: 0,
},
});
expect(report.pages[0]?.metrics).toMatchObject({
dimensionsMatch: false,
geometryNormalized: true,
comparedWidthPx: 40,
});
expect(report.issues.map((issue) => issue.code)).not.toContain(
"RASTER_SIZE_MISMATCH",
);
});
it("展示并校验逐页逻辑页码和位置", async () => {
const baseline = snapshot("Chromium", raster("#111111"));
const candidate = snapshot("Word", raster("#111111"));
const pageNumberLine = (x: number) => ({
text: "— 1 —",
normalizedText: "— 1 —",
bounds: { x, y: 22, width: 2, height: 1 },
baselineY: 23,
role: "page-number" as const,
items: [],
});
baseline.pages[0]!.lines = [pageNumberLine(17)];
baseline.pages[0]!.pageNumberText = "— 1 —";
candidate.pages[0]!.lines = [pageNumberLine(9)];
candidate.pages[0]!.pageNumberText = "— 1 —";
const report = await createPdfVisualDiffReport(baseline, candidate, {
pageSemantics: [
{
physicalPageNumber: 1,
kind: "body-first",
logicalPageNumber: 1,
logicalPageCount: 1,
headerVisible: false,
footerVisible: true,
footerAlignment: "right",
pageNumberText: "— 1 —",
},
],
});
const html = renderPdfVisualDiffHtml(report);
expect(report.status).toBe("failed");
expect(report.pages[0]?.semantics?.candidate).toMatchObject({
pageNumberAlignment: "center",
});
expect(report.issues.map((issue) => issue.code)).toContain(
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
);
expect(html).toContain("页面语义");
expect(html).toContain("正文首页");
expect(html).toContain("逻辑页 1 / 1");
});
it("拒绝本应隐藏却仍然出现的页眉", async () => {
const baseline = snapshot("Chromium", raster("#111111"));
const candidate = snapshot("Word", raster("#111111"));
const headerLine = {
text: "左页眉",
normalizedText: "左页眉",
bounds: { x: 2, y: 1, width: 4, height: 1 },
baselineY: 2,
role: "content" as const,
items: [],
};
baseline.pages[0]!.lines = [headerLine];
candidate.pages[0]!.lines = [headerLine];
const report = await createPdfVisualDiffReport(baseline, candidate, {
pageSemantics: [
{
physicalPageNumber: 1,
kind: "cover",
logicalPageCount: 1,
headerVisible: false,
headerSlots: [{ alignment: "left", text: "左页眉" }],
footerVisible: false,
},
],
});
expect(report.status).toBe("failed");
expect(report.issues.map((issue) => issue.code)).toContain(
"HEADER_VISIBILITY_MISMATCH",
);
});
it("拒绝页眉和页码纵向基线偏差超过 2pt", async () => {
const baseline = snapshot("Chromium", raster("#111111"));
const candidate = snapshot("Word", raster("#111111"));
baseline.pages[0]!.heightPt = 100;
candidate.pages[0]!.heightPt = 100;
const lines = (headerY: number, footerY: number) => [
{
text: "左页眉",
normalizedText: "左页眉",
bounds: { x: 2, y: headerY - 1, width: 4, height: 1 },
baselineY: headerY,
role: "content" as const,
items: [],
},
{
text: "1 / 1",
normalizedText: "1 / 1",
bounds: { x: 9, y: footerY - 1, width: 2, height: 1 },
baselineY: footerY,
role: "page-number" as const,
items: [],
},
];
baseline.pages[0]!.lines = lines(2, 95);
candidate.pages[0]!.lines = lines(5, 92);
const report = await createPdfVisualDiffReport(baseline, candidate, {
pageSemantics: [
{
physicalPageNumber: 1,
kind: "body-first",
logicalPageNumber: 1,
logicalPageCount: 1,
headerVisible: true,
headerSlots: [{ alignment: "left", text: "左页眉" }],
footerVisible: true,
footerAlignment: "center",
pageNumberText: "1 / 1",
},
],
});
expect(report.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
"HEADER_VERTICAL_POSITION_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
]),
);
});
it("按文档锚点与页内相对偏移校验页码并容纳 Chromium 自身逐页抖动", async () => {
const createTwoPageSnapshot = (
label: string,
baselines: readonly [number, number],
): PdfDocumentSnapshot => {
const base = snapshot(label, raster("#111111"));
base.pageCount = 2;
base.pages = baselines.map((baselineY, index) => ({
...base.pages[0]!,
pageNumber: index + 1,
heightPt: 100,
lines: [
{
text: `${index + 1} / 2`,
normalizedText: `${index + 1} / 2`,
bounds: { x: 9, y: baselineY - 1, width: 2, height: 1 },
baselineY,
role: "page-number" as const,
items: [],
},
],
}));
return base;
};
const baseline = createTwoPageSnapshot("Chromium", [95, 93.5]);
const candidate = createTwoPageSnapshot("Word", [95.8, 95.8]);
const semantics: VisualPageSemanticExpectation[] = [1, 2].map(
(pageNumber) => ({
physicalPageNumber: pageNumber,
kind: pageNumber === 1 ? "body-first" : "body-rest",
logicalPageNumber: pageNumber,
logicalPageCount: 2,
headerVisible: false,
footerVisible: true,
footerAlignment: "center",
pageNumberText: `${pageNumber} / 2`,
}),
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
pageSemantics: semantics,
});
expect(report.issues.map((issue) => issue.code)).not.toContain(
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
);
});
it("将格式一致的整段跨页栅格差异标记为正文流动警告", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[contentLine("甲", 8), contentLine("乙丙", 12)], []],
"#cc0000",
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙",
expectedEditableParagraphs: flowParagraphs,
baselinePageSemantics: bodySemantics(2),
candidatePageSemantics: bodySemantics(2),
});
expect(report.status).toBe("warning");
expect(report.basic.bodyFlow).toMatchObject({
detected: true,
legal: true,
movedParagraphIndexes: [1],
});
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
"body-flow",
"body-flow",
]);
expect(report.issues.map((issue) => issue.code)).toContain(
"BODY_FLOW_RASTER_DIFFERENCE",
);
expect(report.issues.map((issue) => issue.code)).not.toContain(
"INK_IOU_BELOW_THRESHOLD",
);
});
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[
[contentLine("甲", 8), contentLine("乙", 12), contentLine("丙", 16)],
[],
],
"#cc0000",
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙",
expectedEditableParagraphs: flowParagraphs,
baselinePageSemantics: bodySemantics(2),
candidatePageSemantics: bodySemantics(2),
});
expect(report.status).toBe("failed");
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
expect(report.pages.every(
(page) => page.rasterComparisonMode === "strict",
)).toBe(true);
expect(report.issues.map((issue) => issue.code)).toContain(
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
);
});
it("允许格式一致的正文自然分页产生物理页数差异", async () => {
const baseline = flowSnapshot(
"baseline",
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[[contentLine("甲", 8), contentLine("乙丙", 12)]],
"#cc0000",
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙",
expectedEditableParagraphs: flowParagraphs,
baselinePageSemantics: bodySemantics(2),
candidatePageSemantics: bodySemantics(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",
);
expect(report.pages[1]).toMatchObject({
rasterComparisonMode: "body-flow",
status: "warning",
});
});
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), contentLine("乙丙", 12), pageNumber("1 / 1")]],
"#111111",
);
const candidate = flowSnapshot(
"candidate",
[
[contentLine("甲", 8), pageNumber("1 / 2")],
[contentLine("乙丙", 8)],
],
"#cc0000",
);
const baselineSemantics = bodySemantics(1).map((item) => ({
...item,
footerVisible: true,
footerAlignment: "center" as const,
pageNumberText: "1 / 1",
}));
const candidateSemantics = bodySemantics(2).map((item, index) => ({
...item,
footerVisible: true,
footerAlignment: "center" as const,
pageNumberText: `${index + 1} / 2`,
}));
const report = await createPdfVisualDiffReport(baseline, candidate, {
expectedEditableText: "甲乙丙",
expectedEditableParagraphs: flowParagraphs,
baselinePageSemantics: baselineSemantics,
candidatePageSemantics: candidateSemantics,
});
expect(report.status).toBe("failed");
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
expect(report.issues.map((issue) => issue.code)).toContain(
"PAGE_NUMBER_VISIBILITY_MISMATCH",
);
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
aggregatePdfTextLines,
buildPageContentText,
normalizePdfEditableText,
normalizePdfText,
type PdfTextItemSnapshot,
} from "../src/index.js";
@@ -39,6 +40,16 @@ describe("PDF 文本行聚合", () => {
]);
});
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
expect(normalizePdfText("戶⼾")).toBe("户户");
});
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
expect(normalizePdfEditableText("• 项目一 ◦ 子项 ▪ 末项  WPS")).toBe(
"项目一子项末项WPS"
);
});
it("只在页眉页脚坐标带识别独立页码", () => {
const lines = aggregatePdfTextLines(
[
+1 -1
View File
@@ -64,7 +64,7 @@ npm run verify:docx-themes
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`
`verify:docx-theme-styles` 使用 Playwright 与 Electron 分别采集 14 套
内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
内置主题的 60 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、
结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。
@@ -57,7 +57,7 @@ local function media_image(media)
return pandoc.Image(
pandoc.Inlines { pandoc.Str(media.alt_text) },
media.path,
"",
media.binding,
{
width = media.width,
height = media.height
@@ -69,6 +69,7 @@ local function replace_image(image)
local media = next_media("image")
image.src = media.path
image.caption = pandoc.Inlines { pandoc.Str(media.alt_text) }
image.title = media.binding
image.attributes.width = media.width
image.attributes.height = media.height
return image
@@ -181,6 +182,9 @@ return {
if structure_plan.titlePolicy.metadataTitle == "suppress" then
transformed.meta.title = nil
end
if structure_plan.metadataPolicy.author == "suppress" then
transformed.meta.author = nil
end
if structure_plan.titlePolicy.firstBodyHeading == "suppress" then
suppress_first_body_heading(transformed.blocks)
end
@@ -0,0 +1,52 @@
---
title: DOCX 媒体视觉专项验收
author: Markdown PDF 导出器研发组
lang: zh-CN
---
# DOCX 媒体视觉专项验收
本样例验证普通图片、宽图、高图、Mermaid 与 ECharts 在 Chromium、
Microsoft Word 和 WPS Writer 中的物理尺寸、宽高比与水平对齐。
## 普通图片
![普通图片:蓝色 4:3](media/normal.png)
## 宽图
![宽图:橙色 8:3](media/wide.png)
## 高图
![高图:绿色 3:8](media/tall.png)
## Mermaid
```mermaid
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#C4B5FD","primaryBorderColor":"#6D28D9","lineColor":"#6D28D9","fontFamily":"Microsoft YaHei"}}}%%
flowchart LR
A[Markdown] --> B[高分辨率 PNG]
B --> C[可编辑 DOCX]
```
## ECharts
```echarts
version: 1
caption: ECharts 紫色柱状图
option:
backgroundColor: "#F9A8D4"
xAxis:
type: category
data: [一月, 二月, 三月, 四月]
yAxis:
type: value
series:
- type: bar
itemStyle:
color: "#7C3AED"
data: [12, 20, 16, 24]
```
最后一段用于确认媒体周围的正文仍保持 Word 原生段落,可以继续编辑。
@@ -9,7 +9,9 @@ import {
import {
PandocDocxConverter,
PandocRuntime,
inspectDocxAcceptance
createDocxMediaAcceptanceExpectation,
inspectDocxAcceptance,
resolveReferencePageOptions
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
@@ -49,6 +51,7 @@ function mediaResource(kind, ordinal, dimensions, altText, caption) {
kindOrdinal: 1,
altText,
...(caption ? { caption } : {}),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -115,6 +118,58 @@ function createThemeTokens(theme) {
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const coverTheme = loadTheme("formal-feasibility");
const semanticDocument = {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
};
const coverSemanticDocument = {
schemaVersion: 1,
profile: "project-report",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [
{
kind: "cover",
nodes: [
{
kind: "group",
role: "project-report-cover",
children: [
{
kind: "text",
role: "project-report-project-name",
text: "智慧园区建设项目"
},
{
kind: "text",
role: "project-report-title",
text: "可行性研究报告"
},
{
kind: "text",
role: "project-report-owner",
label: "建设单位:",
text: "示例建设集团"
}
]
}
],
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
}
]
};
const commonExpectation = {
requiredText: [
"可编辑的中文正文",
@@ -214,6 +269,18 @@ const landscapeLetterConfig = {
startFrom: 3
}
};
const coverA4Config = {
...defaultExportConfig,
name: "可研封面 A4 纵向验收",
themeId: coverTheme.id,
pageDecorationsMode: "theme",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme"
}
};
const variants = [
{
@@ -275,6 +342,41 @@ const variants = [
}
}
}
},
{
id: "project-cover-a4-portrait",
theme: coverTheme,
exportConfig: coverA4Config,
semanticDocument: coverSemanticDocument,
expectation: {
...commonExpectation,
id: "project-cover-a4-portrait",
requiredText: [
...commonExpectation.requiredText,
"智慧园区建设项目",
"可行性研究报告"
],
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 1417,
right: 1134,
bottom: 1417,
left: 1701
}
},
minimumSections: 2,
requireFirstSectionWithoutHeaderFooter: true,
finalPageNumberStart: 1,
requireSectionPagesField: true,
requiredParagraphStyleIds: [
"MdProjectReportProjectName",
"MdProjectReportTitle",
"MdProjectReportOwner"
]
}
}
];
@@ -299,33 +401,51 @@ fs.mkdirSync(outputDirectory, { recursive: true });
const results = [];
for (const variant of variants) {
const variantSemanticDocument =
variant.semanticDocument ?? semanticDocument;
const fileName = `${variant.id}.md`;
const metadata = {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
};
const resolvedPage = resolveReferencePageOptions({
exportConfig: variant.exportConfig,
theme: variant.theme,
fileName,
metadata
});
const media = createMedia();
const result = await converter.convert({
markdown,
fileName: `${variant.id}.md`,
fileName,
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
themeTokens: createThemeTokens(variant.theme),
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: createMedia()
fonts: [],
metadata,
semanticDocument: variantSemanticDocument,
media
});
const report = inspectDocxAcceptance(
result.docx,
variant.expectation
{
...variant.expectation,
media: createDocxMediaAcceptanceExpectation(
media,
variant.expectation.page
),
pageDecorations: {
exportConfig: {
header: resolvedPage.header,
footer: resolvedPage.footer
},
semanticDocument: variantSemanticDocument
}
}
);
const outputPath = path.join(outputDirectory, `${variant.id}.docx`);
fs.writeFileSync(outputPath, result.docx);
@@ -56,6 +56,7 @@ function resource(kind, ordinal, dimensions, caption) {
? "Mermaid 流程图"
: "ECharts 柱状图",
...(caption ? { caption } : {}),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -11,6 +11,7 @@ import {
PandocRuntime,
resolveReferencePageOptions
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
import { renderMarkdown } from "@md-to-pdf/renderer";
import { unzipSync } from "fflate";
@@ -185,10 +186,17 @@ function inspectDocument(
/<w:sectPr(?:\s[^>]*)?>[\s\S]*?<\/w:sectPr>/gu
)
].map((match) => match[0]);
const fullWidthTableCount = countMatches(
documentXml,
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/gu
const tables = [
...documentXml.matchAll(
/<w:tbl(?:\s|>)[\s\S]*?<\/w:tbl>/gu
)
].map((match) => match[0]);
const contentTables = tables.filter((table) =>
/<w:tblStyle\s+w:val="Table"\s*\/>/u.test(table)
);
const fullWidthTableCount = contentTables.filter((table) =>
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/u.test(table)
).length;
const internalMarkerCount = countMatches(
documentXml,
/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu
@@ -216,7 +224,9 @@ function inspectDocument(
bytes: content.byteLength,
page: pageOptions,
paragraphCount: countMatches(documentXml, /<w:p(?:\s|>)/gu),
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
tableCount: tables.length,
contentTableCount: contentTables.length,
structuralTableCount: tables.length - contentTables.length,
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
explicitPageBreaks,
sectionCount: sections.length,
@@ -280,6 +290,7 @@ function createMediaResource(kind, ordinal, kindOrdinal) {
...(kind === "image"
? {}
: { caption: `${kind} 主题验收图 ${kindOrdinal}` }),
alignment: "center",
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
@@ -345,8 +356,8 @@ function readThemeTokenReport(themes) {
const tokensByTheme = new Map(
report.tokenSets.map((tokens) => {
assert(
tokens.slots?.length === 56,
`主题 ${tokens.themeId} 的真实令牌槽位不是 56`
tokens.slots?.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${tokens.themeId} 的真实令牌槽位不是 ${DOCX_STYLE_SLOT_NAMES.length}`
);
assert(
/^[a-f0-9]{64}$/u.test(tokens.themeFingerprint ?? ""),
@@ -483,7 +494,7 @@ for (const result of results) {
`主题 ${result.id} 存在重复标题`
);
assert(
inspection.fullWidthTableCount === inspection.tableCount,
inspection.fullWidthTableCount === inspection.contentTableCount,
`主题 ${result.id} 存在非内容区全宽表格`
);
if (inspection.profile) {
@@ -17,6 +17,16 @@ import {
validateGeneratedDocx,
type DynamicReferenceValidation
} from "./validator.js";
import {
inspectDocxPageDecorations,
type DocxPageDecorationAcceptanceReport,
type DocxPageDecorationExpectation
} from "./page-decoration-acceptance.js";
import {
inspectDocxMediaAcceptance,
type DocxMediaAcceptanceExpectation,
type DocxMediaAcceptanceReport
} from "./media-acceptance.js";
const OFFICE_MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
@@ -74,6 +84,8 @@ export interface DocxAcceptanceExpectation {
minimumFullWidthTables?: number;
maximumTextOccurrences?: Readonly<Record<string, number>>;
forbidInternalMarkers?: boolean;
pageDecorations?: DocxPageDecorationExpectation;
media?: DocxMediaAcceptanceExpectation;
}
export interface DocxAcceptanceReport {
@@ -111,6 +123,8 @@ export interface DocxAcceptanceReport {
imageAltText: string[];
styleIds: string[];
appliedParagraphStyleIds: string[];
pageDecorations?: DocxPageDecorationAcceptanceReport;
media?: DocxMediaAcceptanceReport;
checks: Record<string, true>;
}
@@ -290,6 +304,21 @@ export function inspectDocxAcceptance(
if (!section) {
fail(expectation.id, "page-section", "缺少最终节");
}
const pageDecorations = expectation.pageDecorations
? inspectDocxPageDecorations(
entries,
document,
expectation.pageDecorations,
expectation.id
)
: undefined;
const media = expectation.media
? inspectDocxMediaAcceptance(
content,
expectation.media,
expectation.id
)
: undefined;
const pageSize = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgSz")
)[0];
@@ -401,6 +430,17 @@ export function inspectDocxAcceptance(
);
}
if (expectation.finalPageNumberStart !== undefined) {
const parityOffset = expectation.pageDecorations
? expectation.pageDecorations.exportConfig.footer.enabled &&
expectation.pageDecorations.exportConfig.footer.alignment ===
"outer"
? expectation.pageDecorations.semanticDocument.regions.filter(
(region) => region.kind === "cover" && region.section
).length
: 0
: 0;
const expectedPageNumberStart =
expectation.finalPageNumberStart + parityOffset;
const pageNumber = firstDirectChild(
section,
WORD_NAMESPACE,
@@ -422,7 +462,7 @@ export function inspectDocxAcceptance(
expectation.id,
"final-page-number-start"
),
expectation.finalPageNumberStart
expectedPageNumberStart
);
}
@@ -734,6 +774,8 @@ export function inspectDocxAcceptance(
imageAltText,
styleIds,
appliedParagraphStyleIds,
...(pageDecorations ? { pageDecorations } : {}),
...(media ? { media } : {}),
checks: {
package: true,
page: true,
@@ -742,6 +784,8 @@ export function inspectDocxAcceptance(
pngMedia: true,
styles: true,
sections: true,
...(pageDecorations ? { pageDecorations: true } : {}),
...(media ? { media: true } : {}),
tables: true,
textOccurrences: true,
noAltChunk: true
File diff suppressed because it is too large Load Diff
@@ -145,27 +145,89 @@ function appendField(
});
}
function footerTemplate(footer: FooterConfig) {
return footer.format === "page"
? "${page}"
: footer.format === "page-total"
? "${page} / ${pages}"
: footer.format === "chinese-page-total"
? "第 ${page} 页 / 共 ${pages} 页"
: footer.format === "dash-page"
? "- ${page} -"
: footer.format === "official-page"
? "— ${page} —"
: footer.template || "${page} / ${pages}";
}
function appendInstruction(
paragraph: XmlElement,
value: string,
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
const instruction = appendElement(
run,
WORD_NAMESPACE,
"w:instrText"
);
instruction.setAttributeNS(
XML_NAMESPACE,
"xml:space",
"preserve"
);
instruction.appendChild(
instruction.ownerDocument!.createTextNode(value)
);
}
function appendFieldCharacter(
paragraph: XmlElement,
type: "begin" | "separate" | "end",
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
appendElement(run, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": type,
...(type === "begin" ? { "w:dirty": "true" } : {})
});
}
function appendOffsetPageField(
paragraph: XmlElement,
offset: number,
style: RunStyle
) {
if (offset <= 0) {
appendField(paragraph, "PAGE", style);
return;
}
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " = ", style);
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " PAGE \\* MERGEFORMAT ", style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, String(offset + 1), style);
appendFieldCharacter(paragraph, "end", style);
appendInstruction(paragraph, ` - ${offset} `, style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, "1", style);
appendFieldCharacter(paragraph, "end", style);
}
function appendFooterContent(
paragraph: XmlElement,
footer: FooterConfig,
style: RunStyle
style: RunStyle,
pageNumberOffset = 0
) {
const template =
footer.format === "page"
? "${page}"
: footer.format === "page-total"
? "${page} / ${pages}"
: footer.format === "chinese-page-total"
? "第 ${page} 页 / 共 ${pages} 页"
: footer.format === "dash-page"
? "- ${page} -"
: footer.format === "official-page"
? "— ${page} —"
: footer.template || "${page} / ${pages}";
const tokens = template.split(/(\$\{page\}|\$\{pages\})/gu);
const tokens = footerTemplate(footer).split(
/(\$\{page\}|\$\{pages\})/gu
);
for (const token of tokens) {
if (token === "${page}") {
appendField(paragraph, "PAGE", style);
appendOffsetPageField(paragraph, pageNumberOffset, style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
@@ -179,6 +241,7 @@ function appendParagraphProperties(
options: {
alignment?: "left" | "center" | "right";
position: "header" | "footer";
lineHeightTwips: number;
divider: boolean;
dividerColor: string;
centerTabTwips?: number;
@@ -192,7 +255,9 @@ function appendParagraphProperties(
);
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0"
"w:after": "0",
"w:line": String(options.lineHeightTwips),
"w:lineRule": "exact"
});
if (options.alignment) {
appendElement(properties, WORD_NAMESPACE, "w:jc", {
@@ -200,18 +265,22 @@ function appendParagraphProperties(
});
}
if (
options.centerTabTwips !== undefined &&
options.centerTabTwips !== undefined ||
options.rightTabTwips !== undefined
) {
const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs");
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
if (options.centerTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
}
if (options.rightTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
}
}
if (options.divider) {
const borders = appendElement(
@@ -244,6 +313,7 @@ function appendHeaderParagraph(
divider: boolean;
dividerColor: string;
contentWidthMm: number;
lineHeightTwips: number;
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
@@ -254,6 +324,7 @@ function appendHeaderParagraph(
const contentWidthTwips = millimetersToTwips(options.contentWidthMm);
appendParagraphProperties(paragraph, {
position: "header",
lineHeightTwips: options.lineHeightTwips,
divider: options.divider,
dividerColor: options.dividerColor,
centerTabTwips: Math.round(contentWidthTwips / 2),
@@ -270,7 +341,8 @@ function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string,
contentWidthMm: number
contentWidthMm: number,
empty = false
) {
const document = createWordPart("hdr");
const style: RunStyle = {
@@ -279,12 +351,13 @@ function createHeaderPart(
color: header.color
};
appendHeaderParagraph(document.documentElement!, {
divider: header.showDivider,
divider: !empty && header.showDivider,
dividerColor: header.color,
contentWidthMm,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
if (!empty && slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
@@ -300,7 +373,8 @@ function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false
empty = false,
pageNumberOffset = 0
) {
const document = createWordPart("ftr");
const style: RunStyle = {
@@ -315,12 +389,13 @@ function createFooterPart(
);
appendParagraphProperties(paragraph, {
position: "footer",
divider: footer.showDivider,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
divider: !empty && footer.showDivider,
dividerColor: footer.color,
alignment
});
if (!empty) {
appendFooterContent(paragraph, footer, style);
appendFooterContent(paragraph, footer, style, pageNumberOffset);
}
return serializeXmlPart(document);
}
@@ -460,10 +535,15 @@ export function createHeaderFooterParts(
}> = [];
let headerIndex = 1;
let footerIndex = 1;
const coverPageOffset = options.semanticDocument?.regions.filter(
(region) => region.kind === "cover" && region.section
).length ?? 0;
const usesEvenAndOddPages =
footer.enabled && footer.alignment === "outer";
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
footer.enabled && !footer.showOnFirstPage;
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
if (header.enabled) {
const headerContent = createHeaderPart(
@@ -474,25 +554,50 @@ export function createHeaderFooterParts(
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : []),
...(usesDifferentFirstPage ? ["first"] : [])
...(usesEvenAndOddPages ? ["even"] : [])
] as HeaderFooterReferenceType[]) {
const partName = `word/header${headerIndex++}.xml`;
entries.set(partName, headerContent);
parts.push({ kind: "header", type, partName });
}
if (usesDifferentFirstPage) {
const firstPartName = `word/header${headerIndex++}.xml`;
entries.set(
firstPartName,
createHeaderPart(
header,
options,
fallbackFont,
contentWidthMm,
!header.showOnFirstPage
)
);
parts.push({
kind: "header",
type: "first",
partName: firstPartName
});
}
}
if (footer.enabled) {
const defaultAlignment =
footer.alignment === "outer" ? "right" : footer.alignment;
footer.alignment === "outer"
? coverPageOffset % 2 === 0
? "right"
: "left"
: footer.alignment;
const evenAlignment =
coverPageOffset % 2 === 0 ? "left" : "right";
const defaultPartName = `word/footer${footerIndex++}.xml`;
entries.set(
defaultPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont
fallbackFont,
false,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
@@ -504,7 +609,13 @@ export function createHeaderFooterParts(
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(footer, "left", fallbackFont)
createFooterPart(
footer,
evenAlignment,
fallbackFont,
false,
coverPageOffset
)
);
parts.push({
kind: "footer",
@@ -520,7 +631,8 @@ export function createHeaderFooterParts(
footer,
defaultAlignment,
fallbackFont,
true
!footer.showOnFirstPage,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
+2
View File
@@ -3,7 +3,9 @@ export * from "./document-structure-transform.js";
export * from "./font-embedding.js";
export * from "./font-package-transform.js";
export * from "./header-footer-transform.js";
export * from "./media-acceptance.js";
export * from "./ooxml.js";
export * from "./page-decoration-acceptance.js";
export * from "./pandoc-process.js";
export * from "./pandoc-media.js";
export * from "./pandoc-structure.js";
@@ -0,0 +1,452 @@
import path from "node:path";
import type { PreparedDocxMedia } from "@md-to-pdf/core";
import {
DRAWING_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
firstDirectChild,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import {
preparePandocMedia,
type PandocMediaLayoutItem
} from "./pandoc-media.js";
import { readGeneratedDocxPackage } from "./reference-package.js";
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const IMAGE_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const EMUS_PER_TWIP = 635;
const EMU_TOLERANCE = 20;
const ASPECT_RATIO_TOLERANCE = 0.000_001;
const PNG_SIGNATURE = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
);
export interface DocxMediaAcceptancePage {
widthTwips: number;
heightTwips: number;
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface DocxMediaAcceptanceExpectation {
items: readonly PandocMediaLayoutItem[];
maximumWidthEmu: number;
maximumHeightEmu: number;
}
export interface DocxMediaAcceptanceObservation {
id: string;
kind: PandocMediaLayoutItem["kind"];
ordinal: number;
altText: string;
alignment: PandocMediaLayoutItem["alignment"];
widthEmu: number;
heightEmu: number;
relationshipId: string;
partName: string;
}
export interface DocxMediaAcceptanceReport {
expectedCount: number;
drawingCount: number;
inlineCount: number;
anchorCount: number;
maximumWidthEmu: number;
maximumHeightEmu: number;
observations: DocxMediaAcceptanceObservation[];
checks: {
exactCount: true;
inlineLayout: true;
dimensions: true;
aspectRatio: true;
alignment: true;
relationships: true;
pngMedia: true;
noCroppingOrTransform: true;
};
}
interface Relationship {
id: string;
type: string;
target: string;
external: boolean;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(
`${id} 的 DOCX 媒体验收失败:${check}${detail}`
);
}
function numericAttribute(
element: XmlElement,
name: string,
id: string,
check: string
) {
const raw = element.getAttribute(name);
const value = raw === null ? Number.NaN : Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
fail(id, check, `${name}=${JSON.stringify(raw)}`);
}
return value;
}
function withinTolerance(actual: number, expected: number) {
return Math.abs(actual - expected) <= EMU_TOLERANCE;
}
function paragraphForDrawing(
drawing: XmlElement,
id: string
) {
let current = drawing.parentNode;
while (current) {
const element = current as XmlElement;
if (
element.nodeType === 1 &&
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
) {
return element;
}
current = current.parentNode;
}
fail(id, "paragraph", "Drawing 不在正文段落中");
}
function parseRelationships(content: Uint8Array, id: string) {
const document = parseXmlPart(
content,
"word/_rels/document.xml.rels"
);
return Array.from(
document.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
).map(
(element): Relationship => ({
id: element.getAttribute("Id") ?? "",
type: element.getAttribute("Type") ?? "",
target: element.getAttribute("Target") ?? "",
external: element.getAttribute("TargetMode") === "External"
})
).map((relationship) => {
if (!relationship.id || !relationship.target) {
fail(id, "relationships", "图片关系缺少 ID 或 Target");
}
return relationship;
});
}
function resolveImagePart(target: string, id: string) {
const partName = path.posix.normalize(
path.posix.join("word", target.replaceAll("\\", "/"))
);
if (!partName.startsWith("word/") || partName.includes("../")) {
fail(id, "relationships", `图片关系越界:${target}`);
}
return partName;
}
function hasPngSignature(content: Uint8Array) {
return (
content.byteLength >= PNG_SIGNATURE.byteLength &&
PNG_SIGNATURE.every((value, index) => content[index] === value)
);
}
export function createDocxMediaAcceptanceExpectation(
media: PreparedDocxMedia,
page: DocxMediaAcceptancePage
): DocxMediaAcceptanceExpectation {
const contentWidthTwips =
page.widthTwips -
page.marginsTwips.left -
page.marginsTwips.right;
const contentHeightTwips =
page.heightTwips -
page.marginsTwips.top -
page.marginsTwips.bottom;
if (contentWidthTwips <= 0 || contentHeightTwips <= 0) {
throw new Error("DOCX 媒体验收页面内容区无效");
}
return {
items: preparePandocMedia(media).layout,
maximumWidthEmu: contentWidthTwips * EMUS_PER_TWIP,
maximumHeightEmu: contentHeightTwips * EMUS_PER_TWIP
};
}
export function inspectDocxMediaAcceptance(
content: Uint8Array,
expectation: DocxMediaAcceptanceExpectation,
id = "document"
): DocxMediaAcceptanceReport {
const package_ = readGeneratedDocxPackage(content);
const entries = package_.entries;
const document = parseXmlPart(
entries.get("word/document.xml")!,
"word/document.xml"
);
const relationshipContent = entries.get(
"word/_rels/document.xml.rels"
);
if (!relationshipContent) {
fail(id, "relationships", "缺少 document.xml.rels");
}
const relationships = parseRelationships(relationshipContent, id);
const relationshipsById = new Map(
relationships.map((relationship) => [relationship.id, relationship])
);
if (relationshipsById.size !== relationships.length) {
fail(id, "relationships", "存在重复关系 ID");
}
const drawings = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "drawing")
);
const inlineCount = document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"inline"
).length;
const anchorCount = document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"anchor"
).length;
if (
drawings.length !== expectation.items.length ||
inlineCount !== expectation.items.length ||
anchorCount !== 0
) {
fail(
id,
"inline-layout",
`expected=${expectation.items.length}, drawings=${drawings.length}, inline=${inlineCount}, anchor=${anchorCount}`
);
}
const usedRelationshipIds = new Set<string>();
const usedParts = new Set<string>();
const observations: DocxMediaAcceptanceObservation[] = [];
for (const [index, item] of expectation.items.entries()) {
const drawing = drawings[index]!;
const inline = drawing.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"inline"
)[0];
if (!inline) {
fail(id, "inline-layout", `${item.id} 缺少 wp:inline`);
}
for (const attribute of ["distT", "distB", "distL", "distR"]) {
if (inline.getAttribute(attribute) !== "0") {
fail(id, "inline-layout", `${item.id}${attribute} 非零`);
}
}
const extent = firstDirectChild(
inline,
WORDPROCESSING_DRAWING_NAMESPACE,
"extent"
);
const documentProperties = firstDirectChild(
inline,
WORDPROCESSING_DRAWING_NAMESPACE,
"docPr"
);
if (!extent || !documentProperties) {
fail(id, "drawing-properties", `${item.id} 的属性不完整`);
}
const widthEmu = numericAttribute(
extent,
"cx",
id,
"dimensions"
);
const heightEmu = numericAttribute(
extent,
"cy",
id,
"dimensions"
);
if (
!withinTolerance(widthEmu, item.widthEmu) ||
!withinTolerance(heightEmu, item.heightEmu) ||
widthEmu > expectation.maximumWidthEmu + EMU_TOLERANCE ||
heightEmu > expectation.maximumHeightEmu + EMU_TOLERANCE
) {
fail(
id,
"dimensions",
`${item.id}=${widthEmu}x${heightEmu}, expected=${item.widthEmu}x${item.heightEmu}`
);
}
const ratioDelta = Math.abs(
widthEmu / heightEmu - item.widthEmu / item.heightEmu
) / (item.widthEmu / item.heightEmu);
if (ratioDelta > ASPECT_RATIO_TOLERANCE) {
fail(id, "aspect-ratio", `${item.id} delta=${ratioDelta}`);
}
const transforms = Array.from(
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "xfrm")
);
const transformedExtent =
transforms.length === 1
? firstDirectChild(
transforms[0]!,
DRAWING_NAMESPACE,
"ext"
)
: undefined;
if (!transformedExtent) {
fail(id, "drawing-transform", `${item.id} 缺少唯一 a:xfrm/a:ext`);
}
if (
numericAttribute(transformedExtent, "cx", id, "dimensions") !==
widthEmu ||
numericAttribute(transformedExtent, "cy", id, "dimensions") !==
heightEmu
) {
fail(id, "dimensions", `${item.id} 的双层尺寸不一致`);
}
const transform = transforms[0]!;
if (
transform.hasAttribute("rot") ||
transform.hasAttribute("flipH") ||
transform.hasAttribute("flipV") ||
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "srcRect")
.length > 0
) {
fail(id, "cropping-transform", `${item.id} 存在裁切、旋转或翻转`);
}
const locks = drawing.getElementsByTagNameNS(
DRAWING_NAMESPACE,
"graphicFrameLocks"
);
if (
locks.length !== 1 ||
locks[0]!.getAttribute("noChangeAspect") !== "1"
) {
fail(id, "aspect-lock", `${item.id} 未锁定宽高比`);
}
if (
documentProperties.getAttribute("descr") !== item.altText ||
(documentProperties.getAttribute("title") ?? "").includes(
"mdtp-media:"
)
) {
fail(id, "alternative-text", `${item.id} 的替代文本或标题无效`);
}
const paragraph = paragraphForDrawing(drawing, id);
const properties = firstDirectChild(
paragraph,
WORD_NAMESPACE,
"pPr"
);
const justification = properties
? firstDirectChild(properties, WORD_NAMESPACE, "jc")
: undefined;
const alignment = justification?.getAttributeNS(
WORD_NAMESPACE,
"val"
);
if (alignment !== item.alignment) {
fail(
id,
"alignment",
`${item.id}=${alignment ?? "missing"}, expected=${item.alignment}`
);
}
const blips = Array.from(
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "blip")
);
const relationshipId =
blips.length === 1
? blips[0]!.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"embed"
)
: null;
if (!relationshipId || usedRelationshipIds.has(relationshipId)) {
fail(id, "relationships", `${item.id} 的图片关系缺失或重复`);
}
const relationship = relationshipsById.get(relationshipId);
if (
!relationship ||
relationship.type !== IMAGE_RELATIONSHIP_TYPE ||
relationship.external
) {
fail(id, "relationships", `${item.id} 的图片关系类型无效`);
}
const partName = resolveImagePart(relationship.target, id);
const image = entries.get(partName);
if (
!partName.toLowerCase().endsWith(".png") ||
!image ||
!hasPngSignature(image) ||
usedParts.has(partName)
) {
fail(id, "png-media", `${item.id} 未绑定唯一有效 PNG`);
}
usedRelationshipIds.add(relationshipId);
usedParts.add(partName);
observations.push({
id: item.id,
kind: item.kind,
ordinal: item.ordinal,
altText: item.altText,
alignment: item.alignment,
widthEmu,
heightEmu,
relationshipId,
partName
});
}
const imageRelationships = relationships.filter(
(relationship) => relationship.type === IMAGE_RELATIONSHIP_TYPE
);
if (imageRelationships.length !== usedRelationshipIds.size) {
fail(id, "relationships", "存在未使用或额外的正文图片关系");
}
return {
expectedCount: expectation.items.length,
drawingCount: drawings.length,
inlineCount,
anchorCount,
maximumWidthEmu: expectation.maximumWidthEmu,
maximumHeightEmu: expectation.maximumHeightEmu,
observations,
checks: {
exactCount: true,
inlineLayout: true,
dimensions: true,
aspectRatio: true,
alignment: true,
relationships: true,
pngMedia: true,
noCroppingOrTransform: true
}
};
}
+15
View File
@@ -525,6 +525,21 @@ export function pointsToHalfPoints(value: number) {
return String(Math.round(value * 2));
}
export function wordCharacterSpacingTwips(
fontSizePt: number | undefined,
letterSpacingPt = 0
) {
const quantizationCompensation =
fontSizePt === undefined
? 0
: fontSizePt - Math.round(fontSizePt * 2) / 2;
return String(
Math.round(
(letterSpacingPt + quantizationCompensation) * 20
)
);
}
export function pointsToTwips(value: number) {
return String(Math.round(value * 20));
}
@@ -0,0 +1,440 @@
import path from "node:path";
import type {
ExportConfig,
SemanticDocumentModel
} from "@md-to-pdf/core";
import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
directChildren,
firstDirectChild,
parseXmlPart,
type XmlDocument,
type XmlElement
} from "./ooxml.js";
const HEADER_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
const FOOTER_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
type ReferenceType = "default" | "even" | "first";
export interface DocxPageDecorationExpectation {
exportConfig: Pick<ExportConfig, "header" | "footer">;
semanticDocument: SemanticDocumentModel;
}
export interface DocxPageDecorationAcceptanceReport {
hasCover: boolean;
usesDifferentFirstPage: boolean;
usesEvenAndOddPages: boolean;
bodyPageNumberStart: number;
headerReferenceTypes: ReferenceType[];
footerReferenceTypes: ReferenceType[];
totalPageField: "NUMPAGES" | "SECTIONPAGES" | "none";
}
interface Relationship {
type: string;
targetPart: string;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(
`${id} 的 DOCX 页面装饰验收失败:${check}${detail}`
);
}
function parseRelationships(
entries: ReadonlyMap<string, Uint8Array>,
id: string
) {
const partName = "word/_rels/document.xml.rels";
const content = entries.get(partName);
if (!content) {
fail(id, "relationships", `缺少 ${partName}`);
}
const document = parseXmlPart(content, partName);
const relationships = new Map<string, Relationship>();
for (const element of Array.from(
document.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
const relationshipId = element.getAttribute("Id");
const type = element.getAttribute("Type");
const target = element.getAttribute("Target");
if (!relationshipId || !type || !target) {
fail(id, "relationships", "包含不完整的关系声明");
}
relationships.set(relationshipId, {
type,
targetPart: path.posix.normalize(
path.posix.join("word", target.replace(/^\/+/u, ""))
)
});
}
return relationships;
}
function referenceMap(
section: XmlElement,
kind: "header" | "footer",
relationships: ReadonlyMap<string, Relationship>,
entries: ReadonlyMap<string, Uint8Array>,
id: string
) {
const result = new Map<ReferenceType, string>();
const relationshipType =
kind === "header"
? HEADER_RELATIONSHIP_TYPE
: FOOTER_RELATIONSHIP_TYPE;
for (const reference of directChildren(
section,
WORD_NAMESPACE,
`${kind}Reference`
)) {
const type = reference.getAttributeNS(
WORD_NAMESPACE,
"type"
) as ReferenceType | null;
const relationshipId = reference.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"id"
);
if (!type || !["default", "even", "first"].includes(type)) {
fail(id, `${kind}-reference-type`, type ?? "缺失");
}
if (!relationshipId || result.has(type)) {
fail(id, `${kind}-reference`, relationshipId ?? "缺失关系 ID");
}
const relationship = relationships.get(relationshipId);
const part = relationship
? entries.get(relationship.targetPart)
: undefined;
if (
!relationship ||
relationship.type !== relationshipType ||
!part
) {
fail(id, `${kind}-reference`, `无效关系 ${relationshipId}`);
}
result.set(type, relationship.targetPart);
}
return result;
}
function assertReferenceTypes(
id: string,
kind: "header" | "footer",
actual: ReadonlyMap<ReferenceType, string>,
expected: readonly ReferenceType[]
) {
const actualTypes = [...actual.keys()].sort();
const expectedTypes = [...expected].sort();
if (JSON.stringify(actualTypes) !== JSON.stringify(expectedTypes)) {
fail(
id,
`${kind}-reference-types`,
`期望 ${expectedTypes.join(",") || "无"},实际 ${actualTypes.join(",") || "无"}`
);
}
}
function partContent(
entries: ReadonlyMap<string, Uint8Array>,
partName: string
) {
return new TextDecoder().decode(entries.get(partName)!);
}
function fieldCount(content: string, field: "PAGE" | "NUMPAGES" | "SECTIONPAGES") {
return [...content.matchAll(/<w:instrText[^>]*>([\s\S]*?)<\/w:instrText>/gu)]
.filter((match) =>
new RegExp(`\\b${field}\\b`, "u").test(match[1] ?? "")
).length;
}
function assertEmptyFirstPart(
entries: ReadonlyMap<string, Uint8Array>,
partName: string,
id: string,
kind: "header" | "footer"
) {
const document = parseXmlPart(entries.get(partName)!, partName);
const hasText = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "t")
).some((element) => Boolean(element.textContent?.trim()));
const hasField =
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText").length > 0;
const hasBorder =
document.getElementsByTagNameNS(WORD_NAMESPACE, "pBdr").length > 0;
if (hasText || hasField || hasBorder) {
fail(
id,
`${kind}-first-empty`,
`${partName} 仍包含可见内容、字段或分隔线`
);
}
}
function assertFirstPartMatchesDefault(
entries: ReadonlyMap<string, Uint8Array>,
references: ReadonlyMap<ReferenceType, string>,
id: string,
kind: "header" | "footer"
) {
const defaultPart = references.get("default");
const firstPart = references.get("first");
if (
!defaultPart ||
!firstPart ||
partContent(entries, defaultPart) !== partContent(entries, firstPart)
) {
fail(id, `${kind}-first-content`, "首页部件未继承默认内容");
}
}
function footerAlignment(
entries: ReadonlyMap<string, Uint8Array>,
partName: string
) {
const document = parseXmlPart(entries.get(partName)!, partName);
return document
.getElementsByTagNameNS(WORD_NAMESPACE, "jc")[0]
?.getAttributeNS(WORD_NAMESPACE, "val");
}
function expectedTotalPageField(
footer: ExportConfig["footer"]
): "NUMPAGES" | "none" {
if (!footer.enabled) {
return "none";
}
const usesTotal =
footer.format === "page-total" ||
footer.format === "chinese-page-total" ||
(footer.format === "custom" &&
(footer.template ?? "${page} / ${pages}").includes("${pages}"));
return usesTotal ? "NUMPAGES" : "none";
}
export function inspectDocxPageDecorations(
entries: ReadonlyMap<string, Uint8Array>,
document: XmlDocument,
expectation: DocxPageDecorationExpectation,
id: string
): DocxPageDecorationAcceptanceReport {
const sections = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
);
const bodySection = sections.at(-1);
if (!bodySection) {
fail(id, "body-section", "缺少正文节");
}
const coverIntent = expectation.semanticDocument.regions.find(
(region) => region.kind === "cover" && region.section
)?.section;
const hasCover = Boolean(coverIntent);
const { header, footer } = expectation.exportConfig;
const usesEvenAndOddPages =
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
const coverPageOffset = expectation.semanticDocument.regions.filter(
(region) => region.kind === "cover" && region.section
).length;
const bodyPageNumberStart =
(coverIntent?.followingPageNumberStart ?? footer.startFrom) +
(usesEvenAndOddPages ? coverPageOffset : 0);
if (hasCover) {
if (sections.length < 2) {
fail(id, "cover-section", "封面未形成独立节");
}
const coverSection = sections[0]!;
for (const localName of [
"headerReference",
"footerReference",
"pgNumType",
"titlePg"
]) {
if (firstDirectChild(coverSection, WORD_NAMESPACE, localName)) {
fail(id, "cover-section", `封面节不得包含 w:${localName}`);
}
}
const sectionType = firstDirectChild(
coverSection,
WORD_NAMESPACE,
"type"
)?.getAttributeNS(WORD_NAMESPACE, "val");
if (sectionType !== "nextPage") {
fail(id, "cover-section-break", `期望 nextPage,实际 ${sectionType}`);
}
}
const pageNumberStart = firstDirectChild(
bodySection,
WORD_NAMESPACE,
"pgNumType"
)?.getAttributeNS(WORD_NAMESPACE, "start");
if (pageNumberStart !== String(bodyPageNumberStart)) {
fail(
id,
"body-page-number-start",
`期望 ${bodyPageNumberStart},实际 ${pageNumberStart ?? "缺失"}`
);
}
const hasTitlePage = Boolean(
firstDirectChild(bodySection, WORD_NAMESPACE, "titlePg")
);
if (hasTitlePage !== usesDifferentFirstPage) {
fail(
id,
"body-title-page",
`期望 ${usesDifferentFirstPage},实际 ${hasTitlePage}`
);
}
const relationships = parseRelationships(entries, id);
const headerReferences = referenceMap(
bodySection,
"header",
relationships,
entries,
id
);
const footerReferences = referenceMap(
bodySection,
"footer",
relationships,
entries,
id
);
const expectedHeaderTypes: ReferenceType[] = header.enabled
? [
"default",
...(usesEvenAndOddPages ? (["even"] as const) : []),
...(usesDifferentFirstPage ? (["first"] as const) : [])
]
: [];
const expectedFooterTypes: ReferenceType[] = footer.enabled
? [
"default",
...(usesEvenAndOddPages ? (["even"] as const) : []),
...(usesDifferentFirstPage ? (["first"] as const) : [])
]
: [];
assertReferenceTypes(id, "header", headerReferences, expectedHeaderTypes);
assertReferenceTypes(id, "footer", footerReferences, expectedFooterTypes);
if (usesDifferentFirstPage && header.enabled) {
if (header.showOnFirstPage) {
assertFirstPartMatchesDefault(entries, headerReferences, id, "header");
} else {
assertEmptyFirstPart(entries, headerReferences.get("first")!, id, "header");
}
}
if (usesDifferentFirstPage && footer.enabled) {
if (footer.showOnFirstPage) {
assertFirstPartMatchesDefault(entries, footerReferences, id, "footer");
} else {
assertEmptyFirstPart(entries, footerReferences.get("first")!, id, "footer");
}
}
const defaultFooterAlignment =
footer.alignment === "outer"
? coverPageOffset % 2 === 0
? "right"
: "left"
: footer.alignment;
if (footer.enabled) {
const defaultPart = footerReferences.get("default")!;
if (
footerAlignment(entries, defaultPart) !== defaultFooterAlignment
) {
fail(id, "footer-default-alignment", defaultFooterAlignment);
}
const evenPart = footerReferences.get("even");
const evenFooterAlignment =
coverPageOffset % 2 === 0 ? "left" : "right";
if (
evenPart &&
footerAlignment(entries, evenPart) !== evenFooterAlignment
) {
fail(id, "footer-even-alignment", `期望 ${evenFooterAlignment}`);
}
const firstPart = footerReferences.get("first");
if (
firstPart &&
footer.showOnFirstPage &&
footerAlignment(entries, firstPart) !== defaultFooterAlignment
) {
fail(id, "footer-first-alignment", defaultFooterAlignment);
}
}
const settings = parseXmlPart(
entries.get("word/settings.xml")!,
"word/settings.xml"
);
const hasEvenAndOddSetting = Boolean(
settings.getElementsByTagNameNS(WORD_NAMESPACE, "evenAndOddHeaders")[0]
);
if (hasEvenAndOddSetting !== usesEvenAndOddPages) {
fail(
id,
"even-and-odd-setting",
`期望 ${usesEvenAndOddPages},实际 ${hasEvenAndOddSetting}`
);
}
const baseTotalField = expectedTotalPageField(footer);
const totalPageField =
baseTotalField === "none"
? "none"
: hasCover
? "SECTIONPAGES"
: "NUMPAGES";
for (const partName of footerReferences.values()) {
const content = partContent(entries, partName);
const numberFields = fieldCount(content, "PAGE");
if (partName !== footerReferences.get("first") || footer.showOnFirstPage) {
if (numberFields < 1) {
fail(id, "footer-page-field", `${partName} 缺少 PAGE 字段`);
}
}
if (fieldCount(content, "NUMPAGES") > 0 && totalPageField !== "NUMPAGES") {
fail(id, "footer-total-field", `${partName} 不应包含 NUMPAGES`);
}
if (
fieldCount(content, "SECTIONPAGES") > 0 &&
totalPageField !== "SECTIONPAGES"
) {
fail(id, "footer-total-field", `${partName} 不应包含 SECTIONPAGES`);
}
if (
totalPageField !== "none" &&
(partName !== footerReferences.get("first") || footer.showOnFirstPage) &&
fieldCount(content, totalPageField) < 1
) {
fail(id, "footer-total-field", `${partName} 缺少 ${totalPageField}`);
}
}
return {
hasCover,
usesDifferentFirstPage,
usesEvenAndOddPages,
bodyPageNumberStart,
headerReferenceTypes: [...headerReferences.keys()].sort(),
footerReferenceTypes: [...footerReferences.keys()].sort(),
totalPageField
};
}
+3 -1
View File
@@ -151,6 +151,7 @@ function referenceOptions(
exportConfig: input.exportConfig,
theme: input.theme,
themeTokens: input.themeTokens,
semanticDocument: input.semanticDocument,
fileName: input.fileName,
metadata: {
title: input.metadata.title,
@@ -373,7 +374,8 @@ export class PandocDocxConverter {
const finalized = finalizeGeneratedDocxStructure(
pandocDocx,
structurePlan,
input.themeTokens
input.themeTokens,
preparedMedia.layout
);
docx = embedFontsInGeneratedDocx(
finalized.content,
+38 -1
View File
@@ -11,6 +11,7 @@ import {
export interface PandocMediaMapItem {
id: string;
binding: string;
path: string;
alt_text: string;
caption?: string;
@@ -18,6 +19,19 @@ export interface PandocMediaMapItem {
height: string;
}
export interface PandocMediaLayoutItem {
id: string;
binding: string;
kind: DocxMediaKind;
ordinal: number;
altText: string;
alignment: PreparedDocxMedia["resources"][number]["alignment"];
displayWidthPx: number;
displayHeightPx: number;
widthEmu: number;
heightEmu: number;
}
export interface PandocMediaMap {
image: PandocMediaMapItem[];
mermaid: PandocMediaMapItem[];
@@ -32,8 +46,12 @@ export interface PandocMediaFile {
export interface PreparedPandocMedia {
map: PandocMediaMap;
files: PandocMediaFile[];
layout: PandocMediaLayoutItem[];
}
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
const EMUS_PER_CSS_PIXEL = 9_525;
function pixelsToMillimeters(value: number) {
return (value * 25.4) / DOCX_MEDIA_CSS_DPI;
}
@@ -91,6 +109,7 @@ export function preparePandocMedia(
echarts: []
};
const files: PandocMediaFile[] = [];
const layout: PandocMediaLayoutItem[] = [];
const ids = new Set<string>();
let totalBytes = 0;
const kindCounts: Record<DocxMediaKind, number> = {
@@ -129,8 +148,10 @@ export function preparePandocMedia(
const relativePath = `media/media-${String(
resource.ordinal
).padStart(3, "0")}.png`;
const binding = `${DOCX_MEDIA_BINDING_PREFIX}${resource.id}`;
map[resource.kind].push({
id: resource.id,
binding,
path: relativePath,
alt_text: resource.altText || `${resource.kind} 图片`,
...(resource.caption
@@ -139,6 +160,22 @@ export function preparePandocMedia(
width: physicalLength(resource.displayWidthPx),
height: physicalLength(resource.displayHeightPx)
});
layout.push({
id: resource.id,
binding,
kind: resource.kind,
ordinal: resource.ordinal,
altText: resource.altText || `${resource.kind} 图片`,
alignment: resource.alignment,
displayWidthPx: resource.displayWidthPx,
displayHeightPx: resource.displayHeightPx,
widthEmu: Math.round(
resource.displayWidthPx * EMUS_PER_CSS_PIXEL
),
heightEmu: Math.round(
resource.displayHeightPx * EMUS_PER_CSS_PIXEL
)
});
files.push({
relativePath,
content: resource.content
@@ -147,5 +184,5 @@ export function preparePandocMedia(
if (media.totalBytes !== totalBytes) {
throw new Error("DOCX 媒体总大小声明不一致");
}
return { map, files };
return { map, files, layout };
}
@@ -18,6 +18,7 @@ import {
export interface PandocStructureParagraph {
kind: "paragraph";
styleId?: string | undefined;
layout?: "space-between" | undefined;
segments: string[];
separator: "space" | "tab";
}
@@ -47,6 +48,9 @@ export type PandocStructureBlock =
export interface PandocStructurePlan {
schemaVersion: 1;
metadataPolicy: {
author: "emit" | "suppress";
};
titlePolicy: SemanticDocumentModel["titlePolicy"];
prefix: PandocStructureBlock[];
suffix: PandocStructureBlock[];
@@ -158,6 +162,7 @@ function projectGroupNode(
? {
kind: "paragraph",
...(styleId ? { styleId } : {}),
...(node.layout ? { layout: node.layout } : {}),
segments,
separator: segments.length > 1 ? "tab" : "space"
}
@@ -233,6 +238,9 @@ export function createPandocStructurePlan(
}
return {
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: model.titlePolicy,
prefix,
suffix
@@ -72,6 +72,7 @@ export function createDynamicReferenceCacheKey(
pageDefaults: options.theme.pageDefaults
},
themeTokens: options.themeTokens,
semanticDocument: options.semanticDocument,
paper: options.exportConfig.paper,
pageDecorationsMode:
options.exportConfig.pageDecorationsMode,
@@ -143,9 +144,15 @@ export function createDynamicReferenceDocx(
headerHeightMm: page.header.enabled
? lengthToMillimeters(page.header.height)
: 0,
headerFontSizeMm: page.header.enabled
? lengthToMillimeters(page.header.fontSize)
: 0,
footerHeightMm: page.footer.enabled
? lengthToMillimeters(page.footer.height)
: 0,
footerFontSizeMm: page.footer.enabled
? lengthToMillimeters(page.footer.fontSize)
: 0,
pageNumberStart: page.footer.startFrom,
references: withDecorations.references,
usesDifferentFirstPage:
@@ -5,6 +5,7 @@ import {
resolvePageMargins,
type ExportConfig,
type MarkdownDocumentMetadata,
type SemanticDocumentModel,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
@@ -15,6 +16,7 @@ export interface DynamicReferenceDocxOptions {
fileName: string;
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
themeTokens?: DocxThemeTokenSet;
semanticDocument?: SemanticDocumentModel;
}
export function resolveReferencePageOptions(
@@ -23,7 +23,9 @@ export interface ReferenceSectionOptions {
};
orientation: "portrait" | "landscape";
headerHeightMm: number;
headerFontSizeMm: number;
footerHeightMm: number;
footerFontSizeMm: number;
pageNumberStart: number;
references: HeaderFooterReference[];
usesDifferentFirstPage: boolean;
@@ -104,7 +106,9 @@ export function transformDocumentSectionXml(
millimetersToTwips(
Math.max(
0,
options.margins.top - options.headerHeightMm
options.margins.top -
options.headerHeightMm +
options.headerFontSizeMm * 0.25
)
)
),
@@ -112,7 +116,9 @@ export function transformDocumentSectionXml(
millimetersToTwips(
Math.max(
0,
options.margins.bottom - options.footerHeightMm
options.margins.bottom -
options.footerHeightMm -
options.footerFontSizeMm * 0.25
)
)
),
+122 -23
View File
@@ -25,6 +25,7 @@ import {
pointsToTwips,
removeDirectChildren,
serializeXmlPart,
wordCharacterSpacingTwips,
type XmlElement
} from "./ooxml.js";
@@ -62,7 +63,8 @@ function setRunStyle(
"i",
"iCs",
"u",
"shd"
"shd",
"spacing"
);
if (options.fonts) {
setFonts(parent, options.fonts);
@@ -100,6 +102,9 @@ function setRunStyle(
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:val": wordCharacterSpacingTwips(options.sizePt)
});
}
}
@@ -283,10 +288,16 @@ function applyTokenRunStyle(
});
}
}
if (token.letterSpacingPt !== undefined) {
if (
token.letterSpacingPt !== undefined ||
token.fontSizePt !== undefined
) {
removeDirectChildren(parent, WORD_NAMESPACE, "spacing");
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:val": pointsToTwips(token.letterSpacingPt)
"w:val": wordCharacterSpacingTwips(
token.fontSizePt,
token.letterSpacingPt ?? 0
)
});
}
toggleProperty(parent, "b", token.bold);
@@ -299,7 +310,8 @@ function applyTokenRunStyle(
function applyTokenBorders(
parent: XmlElement,
token: DocxSlotStyleToken
token: DocxSlotStyleToken,
horizontalContentPaddingInBorderSpace = false
) {
if (!token.borders) {
return;
@@ -330,7 +342,18 @@ function applyTokenBorders(
0,
Math.min(
31,
Math.round(token.paddingPt?.[side] ?? 0)
Math.round(
horizontalContentPaddingInBorderSpace && side === "left"
? Math.floor(
Math.max(
0,
(token.paddingPt?.left ?? 0) +
(token.contentPaddingLeftPt ?? 0) -
border.widthPt * 2
)
)
: token.paddingPt?.[side] ?? 0
)
)
)
),
@@ -342,11 +365,34 @@ function applyTokenBorders(
function applyTokenParagraphStyle(
parent: XmlElement,
token: DocxSlotStyleToken,
fallbackFontSizePt: number
fallbackFontSizePt: number,
horizontalPaddingThroughBorderSpace = false
) {
if (
for (const propertyName of ["autoSpaceDE", "autoSpaceDN"] as const) {
const automaticSpacing = ensureDirectElement(
parent,
WORD_NAMESPACE,
`w:${propertyName}`
);
setWordAttribute(automaticSpacing, "val", "0");
}
const spacingBeforePt =
token.spacingBeforePt !== undefined ||
(token.paddingPt?.top ?? 0) > 0
? (token.spacingBeforePt ?? 0) +
(token.borders?.top ? 0 : token.paddingPt?.top ?? 0)
: undefined;
const spacingAfterPt =
token.spacingAfterPt !== undefined ||
(token.paddingPt?.bottom ?? 0) > 0
? (token.spacingAfterPt ?? 0) +
(token.borders?.bottom
? 0
: token.paddingPt?.bottom ?? 0)
: undefined;
if (
spacingBeforePt !== undefined ||
spacingAfterPt !== undefined ||
token.lineSpacing !== undefined
) {
const spacing = ensureDirectElement(
@@ -354,18 +400,18 @@ function applyTokenParagraphStyle(
WORD_NAMESPACE,
"w:spacing"
);
if (token.spacingBeforePt !== undefined) {
if (spacingBeforePt !== undefined) {
setWordAttribute(
spacing,
"before",
pointsToTwips(token.spacingBeforePt)
pointsToTwips(spacingBeforePt)
);
}
if (token.spacingAfterPt !== undefined) {
if (spacingAfterPt !== undefined) {
setWordAttribute(
spacing,
"after",
pointsToTwips(token.spacingAfterPt)
pointsToTwips(spacingAfterPt)
);
}
if (token.lineSpacing !== undefined) {
@@ -380,10 +426,28 @@ function applyTokenParagraphStyle(
setWordAttribute(spacing, "lineRule", "exact");
}
}
const leftIndentPt =
token.leftIndentPt !== undefined ||
(token.paddingPt?.left ?? 0) > 0 ||
(token.contentPaddingLeftPt ?? 0) > 0 ||
(token.borders?.left?.widthPt ?? 0) > 0
? (token.leftIndentPt ?? 0) +
(token.paddingPt?.left ?? 0) +
(token.contentPaddingLeftPt ?? 0) +
(token.borders?.left?.widthPt ?? 0)
: undefined;
const rightIndentPt =
token.rightIndentPt !== undefined ||
(token.paddingPt?.right ?? 0) > 0 ||
(token.borders?.right?.widthPt ?? 0) > 0
? (token.rightIndentPt ?? 0) +
(token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0)
: undefined;
if (
token.firstLineIndentPt !== undefined ||
token.leftIndentPt !== undefined ||
token.rightIndentPt !== undefined
leftIndentPt !== undefined ||
rightIndentPt !== undefined
) {
const indent = ensureDirectElement(
parent,
@@ -398,19 +462,19 @@ function applyTokenParagraphStyle(
);
indent.removeAttributeNS(WORD_NAMESPACE, "firstLineChars");
}
if (token.leftIndentPt !== undefined) {
if (leftIndentPt !== undefined) {
setWordAttribute(
indent,
"left",
pointsToTwips(token.leftIndentPt)
pointsToTwips(leftIndentPt)
);
indent.removeAttributeNS(WORD_NAMESPACE, "leftChars");
}
if (token.rightIndentPt !== undefined) {
if (rightIndentPt !== undefined) {
setWordAttribute(
indent,
"right",
pointsToTwips(token.rightIndentPt)
pointsToTwips(rightIndentPt)
);
}
}
@@ -431,7 +495,11 @@ function applyTokenParagraphStyle(
"w:fill": colorValue(token.backgroundColor)
});
}
applyTokenBorders(parent, token);
applyTokenBorders(
parent,
token,
horizontalPaddingThroughBorderSpace
);
toggleProperty(parent, "keepLines", token.keepLines);
toggleProperty(parent, "keepNext", token.keepWithNext);
toggleProperty(
@@ -864,7 +932,8 @@ function applyTableAndCaption(
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
"w:val": style.caption.alignment
"w:val":
styleId === "ImageCaption" ? "center" : style.caption.alignment
});
setRunStyle(
ensureDirectElement(caption, WORD_NAMESPACE, "w:rPr"),
@@ -940,7 +1009,14 @@ function applyTableTokenStyles(
});
}
}
if (tableToken?.borders) {
const tableBorders =
tableToken?.borders || cellToken?.borders
? {
...cellToken?.borders,
...tableToken?.borders
}
: undefined;
if (tableBorders) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblBorders");
const borders = appendElement(
tblPr,
@@ -953,7 +1029,25 @@ function applyTableTokenStyles(
"bottom",
"left"
] as const) {
const border = tableToken.borders[side];
const border = tableBorders[side];
if (!border) {
continue;
}
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
"w:val": border.style,
"w:sz": String(Math.round(border.widthPt * 8)),
"w:space": "0",
"w:color": colorValue(border.color)
});
}
const insideHorizontal =
cellToken?.borders?.top ?? cellToken?.borders?.bottom;
const insideVertical =
cellToken?.borders?.left ?? cellToken?.borders?.right;
for (const [side, border] of [
["insideH", insideHorizontal],
["insideV", insideVertical]
] as const) {
if (!border) {
continue;
}
@@ -1067,14 +1161,19 @@ function applyTokenStyles(
binding.basedOn
);
if (binding.type === "paragraph") {
const paragraphToken =
binding.styleId === "ImageCaption"
? { ...entry.style, alignment: "center" as const }
: entry.style;
applyTokenParagraphStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
),
entry.style,
fallbackFontSizeForSlot(slot, fallback)
paragraphToken,
fallbackFontSizeForSlot(slot, fallback),
binding.styleId === "SourceCode"
);
}
applyTokenRunStyle(
@@ -98,7 +98,17 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
"official-signatory": [paragraph("MdOfficialSignatory")],
"official-title": [paragraph("MdOfficialTitle")],
"official-signature": [paragraph("MdOfficialSignature")],
"official-signature-issuer": [
paragraph("MdOfficialSignatureIssuer")
],
"official-signature-date": [
paragraph("MdOfficialSignatureDate")
],
"official-edition": [paragraph("MdOfficialEdition")],
"official-copy-to": [paragraph("MdOfficialCopyTo")],
"official-printing-row": [
paragraph("MdOfficialPrintingRow")
],
"briefing-masthead": [paragraph("MdBriefingMasthead")],
"briefing-meta": [paragraph("MdBriefingMeta")],
"briefing-title": [paragraph("MdBriefingTitle")],
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import { defaultExportConfig } from "@md-to-pdf/core";
import { inspectDocxAcceptance } from "../src/index.js";
const encoder = new TextEncoder();
@@ -38,36 +39,53 @@ function createAcceptanceDocx(
duplicateText?: boolean;
internalMarker?: boolean;
invalidPropertyOrder?: boolean;
coverPageNumber?: boolean;
bodyTitlePage?: boolean;
footerAlignment?: "left" | "center" | "right";
evenAndOddHeaders?: boolean;
logicalOuterFooter?: boolean;
nativeOuterFooter?: boolean;
} = {}
) {
return zipSync({
"[Content_Types].xml": xml(
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>${overrides.nativeOuterFooter ? '<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' : ""}</Types>`
),
"_rels/.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
),
"word/document.xml": xml(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="1"/></w:sectPr></w:body></w:document>`
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/>${overrides.coverPageNumber ? '<w:pgNumType w:start="1"/>' : ""}</w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/>${overrides.nativeOuterFooter ? '<w:footerReference w:type="even" r:id="rIdEvenFooter"/>' : ""}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="${overrides.nativeOuterFooter ? "2" : "1"}"/>${overrides.bodyTitlePage ? "<w:titlePg/>" : ""}</w:sectPr></w:body></w:document>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
),
"word/settings.xml": xml(`<w:settings xmlns:w="${word}"/>`),
"word/settings.xml": xml(
`<w:settings xmlns:w="${word}">${overrides.evenAndOddHeaders ? "<w:evenAndOddHeaders/>" : ""}</w:settings>`
),
"word/fontTable.xml": xml(`<w:fonts xmlns:w="${word}"/>`),
"word/numbering.xml": xml(`<w:numbering xmlns:w="${word}"/>`),
"word/theme/theme1.xml": xml(
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
),
"word/footer1.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
overrides.logicalOuterFooter
? `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:tabs><w:tab w:val="right" w:pos="9000"/></w:tabs></w:pPr><w:r><w:instrText> IF </w:instrText></w:r><w:r><w:instrText> MOD( </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES </w:instrText></w:r></w:p></w:ftr>`
: `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="${overrides.nativeOuterFooter ? "left" : (overrides.footerAlignment ?? "center")}"/></w:pPr><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
...(overrides.nativeOuterFooter
? {
"word/footer2.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
)
}
: {}),
"word/footnotes.xml": xml(
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
),
"word/media/image1.png": png,
"word/_rels/document.xml.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>${overrides.nativeOuterFooter ? '<Relationship Id="rIdEvenFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>' : ""}<Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
),
...(overrides.altChunk
? { "word/chunk.html": encoder.encode("<p>整体 HTML</p>") }
@@ -110,7 +128,38 @@ const expectation = {
maximumTextOccurrences: {
可编辑正文: 1
},
forbidInternalMarkers: true
forbidInternalMarkers: true,
pageDecorations: {
exportConfig: {
header: defaultExportConfig.header,
footer: defaultExportConfig.footer
},
semanticDocument: {
schemaVersion: 1 as const,
titlePolicy: {
metadataTitle: "suppress" as const,
firstBodyHeading: "keep" as const
},
regions: [
{
kind: "cover" as const,
nodes: [
{
kind: "text" as const,
role: "project-report-title" as const,
text: "封面"
}
],
section: {
headerFooter: "none" as const,
pageNumber: "hidden" as const,
breakAfter: "next-page" as const,
followingPageNumberStart: 1
}
}
]
}
}
};
describe("DOCX 自动验收器", () => {
@@ -142,6 +191,15 @@ describe("DOCX 自动验收器", () => {
noAltChunk: true
})
);
expect(report.pageDecorations).toEqual({
hasCover: true,
usesDifferentFirstPage: false,
usesEvenAndOddPages: false,
bodyPageNumberStart: 1,
headerReferenceTypes: [],
footerReferenceTypes: ["default"],
totalPageField: "SECTIONPAGES"
});
});
it("拒绝通过 altChunk 嵌入的正文 HTML", () => {
@@ -176,4 +234,67 @@ describe("DOCX 自动验收器", () => {
)
).toThrow("w:pPr 子节点顺序无效");
});
it("拒绝封面节残留页码和正文节错误首页设置", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ coverPageNumber: true }),
expectation
)
).toThrow("封面节不得包含 w:pgNumType");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ bodyTitlePage: true }),
expectation
)
).toThrow("body-title-page");
});
it("拒绝页码位置和奇偶页设置偏离导出配置", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ footerAlignment: "right" }),
expectation
)
).toThrow("footer-default-alignment");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ evenAndOddHeaders: true }),
expectation
)
).toThrow("even-and-odd-setting");
});
it("封面重启页码时要求原生奇偶页脚表达外侧页码", () => {
const outerExpectation = {
...expectation,
pageDecorations: {
...expectation.pageDecorations,
exportConfig: {
...expectation.pageDecorations.exportConfig,
footer: {
...expectation.pageDecorations.exportConfig.footer,
alignment: "outer" as const
}
}
}
};
const report = inspectDocxAcceptance(
createAcceptanceDocx({
evenAndOddHeaders: true,
nativeOuterFooter: true,
footerAlignment: "left"
}),
outerExpectation
);
expect(report.pageDecorations).toMatchObject({
hasCover: true,
usesEvenAndOddPages: true,
footerReferenceTypes: ["default", "even"]
});
expect(() =>
inspectDocxAcceptance(createAcceptanceDocx(), outerExpectation)
).toThrow("body-page-number-start");
});
});
@@ -5,6 +5,7 @@ import {
readGeneratedDocxPackage,
readReferenceDocxPackage,
writeGeneratedDocxPackage,
type PandocMediaLayoutItem,
type PandocStructurePlan
} from "../src/index.js";
import { createTestBaselineReference } from "./reference-test-fixture.js";
@@ -15,6 +16,12 @@ const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const relationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const wordprocessingDrawing =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/main";
const picture =
"http://schemas.openxmlformats.org/drawingml/2006/picture";
function generatedFixture() {
const baseline = readReferenceDocxPackage(
@@ -26,11 +33,17 @@ function generatedFixture() {
encoder.encode(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</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:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<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"/>` +
`<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>` +
`</wp:inline></w:drawing></w:r></w:p>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
`</w:body></w:document>`
)
@@ -46,6 +59,9 @@ function generatedFixture() {
const plan: PandocStructurePlan = {
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
@@ -57,7 +73,15 @@ const plan: PandocStructurePlan = {
slot: "tender-cover",
startMarker: "CONTAINER_START",
endMarker: "CONTAINER_END",
blocks: []
blocks: [
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["发文字号", "签发人"],
separator: "tab"
}
]
},
{
kind: "section-break",
@@ -85,6 +109,17 @@ const tokens: DocxThemeTokenSet = {
style: {
fontCandidates: [],
backgroundColor: "#f5f5f5",
spacingBeforePt: 7,
spacingAfterPt: 51,
paddingPt: {
top: 3,
right: 5,
bottom: 11,
left: 4
},
widthPercent: 52,
leftIndentPt: 230.4,
rightIndentPt: 0,
pageBreakAfter: true,
keepLines: true,
borders: {
@@ -121,6 +156,30 @@ const tokens: DocxThemeTokenSet = {
keepLines: true
}
},
{
slot: "table-cell",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontSizePt: 10,
lineSpacing: 1.8
}
},
{
slot: "table-header",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontSizePt: 10,
bold: true,
lineSpacing: 1.5,
alignment: "center",
color: "#ffffff",
backgroundColor: "#17324d"
}
},
{
slot: "heading-1",
source: "computed-css",
@@ -134,8 +193,23 @@ const tokens: DocxThemeTokenSet = {
diagnostics: []
};
const mediaLayout: PandocMediaLayoutItem[] = [
{
id: "docx-media-1",
binding: "mdtp-media:docx-media-1",
kind: "mermaid",
ordinal: 1,
altText: "处理流程",
alignment: "right",
displayWidthPx: 320,
displayHeightPx: 180,
widthEmu: 3_048_000,
heightEmu: 1_714_500
}
];
describe("生成 DOCX 结构收口", () => {
it("生成真实分节、正文页码、节总页数和固定宽度表格", () => {
it("生成真实分节、正文页码、节总页数和全宽自适应表格", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
@@ -170,26 +244,367 @@ describe("生成 DOCX 结构收口", () => {
'<w:tblW w:w="5000" w:type="pct"/>'
);
expect(documentXml).toContain(
'<w:tblLayout w:type="fixed"/>'
'<w:tblLayout w:type="autofit"/>'
);
expect(documentXml).toContain('<w:gridCol w:w="3302"/>');
expect(documentXml).toContain('<w:gridCol w:w="6604"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="Figure"\/>[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing[^>]*w:line="240"[^>]*w:lineRule="auto"[^>]*w:before="300"[^>]*w:after="300"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toContain('<w:gridCol w:w="1000"/>');
expect(documentXml).toContain('<w:gridCol w:w="2000"/>');
expect(documentXml).toContain('<w:tcW w:w="0" w:type="auto"/>');
expect(
documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="300"[^>]*w:lineRule="exact"/gu
)
).toHaveLength(2);
expect(
documentXml.match(
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/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:jc w:val="center"\/>/gu)?.length ?? 0
).toBeGreaterThanOrEqual(2);
const tableParagraph = documentXml.match(
/<w:p>[\s\S]*?<w:t>A<\/w:t>[\s\S]*?<\/w:p>/u
)?.[0];
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
expect(documentXml).toContain("<w:cantSplit/>");
expect(documentXml).toContain(
'<w:tab w:val="right" w:pos="9806"/>'
);
expect(documentXml).toContain('w:fill="F5F5F5"');
expect(documentXml).toContain(
'<w:spacing w:before="140"/>'
);
expect(documentXml).toContain(
'<w:spacing w:after="1020"/>'
);
expect(
documentXml.match(
/<w:ind w:left="4835" w:right="100"\/>/gu
)
).toHaveLength(2);
expect(documentXml).toContain(
'<w:top w:val="single" w:sz="8" w:space="3" w:color="111111"/>'
);
expect(documentXml).toContain(
'<w:bottom w:val="single" w:sz="8" w:space="11" w:color="111111"/>'
);
expect(
documentXml.match(/<w:br w:type="page"\/>/gu)
).toHaveLength(1);
const coverParagraph = documentXml.match(
/<w:p(?:\s|>)[\s\S]*?[\s\S]*?<\/w:p>/u
)?.[0];
expect(coverParagraph).toContain("<w:sectPr>");
expect(coverParagraph).not.toContain(
'<w:br w:type="page"/>'
);
expect(footerXml).toContain("SECTIONPAGES");
expect(footerXml).not.toContain(" NUMPAGES ");
expect(result.report).toEqual({
containerCount: 1,
editableContainerTableCount: 0,
sectionCount: 1,
tableCount: 1,
pageBreakAfterCount: 1,
sectionPageFieldCount: 1
sectionPageFieldCount: 1,
spaceBetweenParagraphCount: 1,
mediaDrawingCount: 1
});
});
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
mediaLayout
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(result.report.mediaDrawingCount).toBe(1);
expect(documentXml).toMatch(
/<wp:inline\b[^>]*distT="0"[^>]*distB="0"[^>]*distL="0"[^>]*distR="0"[^>]*>/u
);
expect(documentXml).toContain(
'<wp:extent cx="3048000" cy="1714500"/>'
);
expect(documentXml).toContain(
'<a:ext cx="3048000" cy="1714500"/>'
);
expect(documentXml).toMatch(
/<a:graphicFrameLocks\b[^>]*noChangeAspect="1"[^>]*\/>/u
);
expect(documentXml).toContain(
'descr="处理流程" title=""'
);
expect(documentXml).toContain('<w:jc w:val="right"/>');
expect(documentXml).not.toContain("mdtp-media:");
expect(documentXml).not.toContain('rot="60000"');
expect(documentXml).not.toContain('flipH="1"');
});
it("拒绝缺失或重复的媒体布局绑定", () => {
expect(() =>
finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[{ ...mediaLayout[0]!, binding: "mdtp-media:missing" }]
)
).toThrow("稳定绑定");
expect(() =>
finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[mediaLayout[0]!, { ...mediaLayout[0]!, id: "duplicate" }]
)
).toThrow("重复绑定");
});
it("将带最小高度的分节封面映射为可编辑单元格容器", () => {
const tableTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 620,
verticalAlignment: "center" as const,
childAlignment: "center" as const
}
}
: entry
).concat({
slot: "official-issue-row" as const,
source: "computed-css" as const,
confidence: "exact" as const,
style: {
fontCandidates: [],
spacingAfterPt: 4
}
}, {
slot: "tender-title" as const,
source: "computed-css" as const,
confidence: "exact" as const,
style: {
fontCandidates: [],
fontSizePt: 20,
letterSpacingPt: 1,
spacingBeforePt: 6,
borders: {
bottom: {
widthPt: 1,
style: "single" as const,
color: "#111111"
}
}
}
})
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tableTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain("<w:tbl>");
expect(documentXml).toContain(
'<w:trHeight w:val="12400" w:hRule="exact"/>'
);
expect(documentXml).toContain(
'<w:tblInd w:w="4608" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml).toContain("<w:tblBorders>");
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t><\/w:t>/u
);
expect(documentXml).toContain('<w:tblLook w:val="0000"');
expect(documentXml).toMatch(
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
);
expect(
documentXml.match(/<w:tbl(?:\s|>)/gu)
).toHaveLength(2);
expect(result.report).toMatchObject({
containerCount: 1,
editableContainerTableCount: 1,
sectionCount: 1,
tableCount: 1
});
});
it("将封面最小高度封顶到当前节的页面内容区", () => {
const oversizedTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 900,
verticalAlignment: "center" as const,
childAlignment: "left" as const
}
}
: entry
).concat(
{
slot: "official-issue-row" as const,
source: "computed-css" as const,
confidence: "exact" as const,
style: {
fontCandidates: [],
fontSizePt: 12,
lineSpacing: 1.5
}
},
{
slot: "tender-title" as const,
source: "computed-css" as const,
confidence: "exact" as const,
style: {
fontCandidates: [],
fontSizePt: 20,
lineSpacing: 1.2
}
}
)
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
oversizedTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:trHeight w:val="14838" w:hRule="exact"/>'
);
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
entries.set(
"word/numbering.xml",
encoder.encode(
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum></w:numbering>`
)
);
const listTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.concat({
slot: "unordered-list" as const,
source: "computed-css" as const,
confidence: "exact" as const,
style: {
fontCandidates: [],
paddingPt: {
top: 0,
right: 0,
bottom: 0,
left: 30
}
}
})
};
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
listTokens
);
const numberingXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/numbering.xml"
)!
);
expect(numberingXml).toContain(
'<w:ind w:left="960" w:hanging="360"/>'
);
expect(numberingXml).toContain(
'<w:ind w:left="1680" w:hanging="360"/>'
);
});
it("按最终页面内容区重算右置百分比容器缩进", () => {
const relativeTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
confidence: "exact" as const,
style: {
fontCandidates: [],
widthPercent: 52,
leftIndentPt: 230.4,
rightIndentPt: 0,
keepLines: true
}
}
: entry
)
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
relativeTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
// A4 内容区为 11906 - 1000 - 1000 = 9906 twips
// 52% 右置容器的左侧空余应为 9906 × 48% = 4755 twips。
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdOfficialIssueRow"\/>[\s\S]*?<w:ind w:left="4755" w:right="0"\/>/u
);
expect(documentXml).not.toContain('w:left="4608"');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdOfficialSignatureDate"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
);
});
it("拒绝缺失或重复的内部结构标记", () => {
expect(() =>
finalizeGeneratedDocxStructure(
@@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
inspectDocxMediaAcceptance,
readReferenceDocxPackage,
writeGeneratedDocxPackage,
type DocxMediaAcceptanceExpectation
} from "../src/index.js";
import { createTestBaselineReference } from "./reference-test-fixture.js";
const encoder = new TextEncoder();
const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const officeRelationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const packageRelationships =
"http://schemas.openxmlformats.org/package/2006/relationships";
const wordprocessingDrawing =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/main";
const picture =
"http://schemas.openxmlformats.org/drawingml/2006/picture";
const imageRelationship =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const documentXml =
`<w:document xmlns:w="${word}" xmlns:r="${officeRelationships}" xmlns:wp="${wordprocessingDrawing}" xmlns:a="${drawing}" xmlns:pic="${picture}"><w:body>` +
`<w:p><w:pPr><w:pStyle w:val="Figure"/><w:jc w:val="center"/></w:pPr><w:r><w:drawing>` +
`<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="3048000" cy="1524000"/><wp:docPr id="1" name="Picture" descr="验收图片" title=""/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>` +
`<a:graphic><a:graphicData uri="${picture}"><pic:pic><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><a:off x="0" y="0"/><a:ext cx="3048000" cy="1524000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
`</wp:inline></w:drawing></w:r></w:p>` +
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/></w:sectPr>` +
`</w:body></w:document>`;
const relationshipsXml =
`<Relationships xmlns="${packageRelationships}">` +
`<Relationship Id="rIdImage" Type="${imageRelationship}" Target="media/image1.png"/>` +
`</Relationships>`;
const png = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
0,
0x49,
0x48,
0x44,
0x52,
0,
0,
0,
1,
0,
0,
0,
1
);
const expectation: DocxMediaAcceptanceExpectation = {
items: [
{
id: "docx-media-1",
binding: "mdtp-media:docx-media-1",
kind: "image",
ordinal: 1,
altText: "验收图片",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 160,
widthEmu: 3_048_000,
heightEmu: 1_524_000
}
],
maximumWidthEmu: 6_000_000,
maximumHeightEmu: 9_000_000
};
function fixture(options: {
document?: (value: string) => string;
relationships?: (value: string) => string;
image?: Uint8Array;
} = {}) {
const baseline = readReferenceDocxPackage(
createTestBaselineReference()
);
const entries = new Map(baseline.entries);
entries.set(
"word/document.xml",
encoder.encode(options.document?.(documentXml) ?? documentXml)
);
entries.set(
"word/_rels/document.xml.rels",
encoder.encode(
options.relationships?.(relationshipsXml) ?? relationshipsXml
)
);
entries.set("word/media/image1.png", options.image ?? png);
return writeGeneratedDocxPackage(entries);
}
describe("DOCX 媒体 OOXML 门禁", () => {
it("验收内联尺寸、比例、对齐和唯一 PNG 关系", () => {
const report = inspectDocxMediaAcceptance(
fixture(),
expectation,
"media-fixture"
);
expect(report).toMatchObject({
expectedCount: 1,
drawingCount: 1,
inlineCount: 1,
anchorCount: 0,
observations: [
{
id: "docx-media-1",
widthEmu: 3_048_000,
heightEmu: 1_524_000,
alignment: "center",
relationshipId: "rIdImage",
partName: "word/media/image1.png"
}
]
});
});
it.each([
{
name: "浮动锚点",
check: "inline-layout",
document: (value: string) =>
value
.replace("<wp:inline ", "<wp:anchor ")
.replace("</wp:inline>", "</wp:anchor>")
},
{
name: "双层尺寸不一致",
check: "dimensions",
document: (value: string) =>
value.replace(
'<a:ext cx="3048000" cy="1524000"/>',
'<a:ext cx="3047000" cy="1524000"/>'
)
},
{
name: "比例漂移",
check: "aspect-ratio",
document: (value: string) =>
value.replaceAll('cx="3048000"', 'cx="3048010"')
},
{
name: "错误对齐",
check: "alignment",
document: (value: string) =>
value.replace(
'<w:jc w:val="center"/>',
'<w:jc w:val="left"/>'
)
},
{
name: "缺少比例锁",
check: "aspect-lock",
document: (value: string) =>
value.replace('noChangeAspect="1"', 'noChangeAspect="0"')
},
{
name: "图片裁切",
check: "cropping-transform",
document: (value: string) =>
value.replace("<pic:blipFill>", "<pic:blipFill><a:srcRect/>")
},
{
name: "旋转图片",
check: "cropping-transform",
document: (value: string) =>
value.replace("<a:xfrm>", '<a:xfrm rot="60000">')
},
{
name: "替代文本错位",
check: "alternative-text",
document: (value: string) =>
value.replace('descr="验收图片"', 'descr="错误图片"')
},
{
name: "内部绑定残留",
check: "alternative-text",
document: (value: string) =>
value.replace('title=""', 'title="mdtp-media:docx-media-1"')
},
{
name: "图片关系缺失",
check: "relationships",
document: (value: string) =>
value.replace('r:embed="rIdImage"', 'r:embed="missing"')
}
])("拒绝$name", ({ check, document }) => {
expect(() =>
inspectDocxMediaAcceptance(
fixture({ document }),
expectation,
"tampered"
)
).toThrow(check);
});
it("拒绝外部、非 PNG、伪 PNG 和额外图片关系", () => {
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace("/>", ' TargetMode="External"/>')
}),
expectation,
"external"
)
).toThrow("relationships");
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace("image1.png", "image1.jpg")
}),
expectation,
"jpeg"
)
).toThrow("png-media");
expect(() =>
inspectDocxMediaAcceptance(
fixture({ image: new Uint8Array(24) }),
expectation,
"fake-png"
)
).toThrow("png-media");
expect(() =>
inspectDocxMediaAcceptance(
fixture({
relationships: (value) =>
value.replace(
"</Relationships>",
`<Relationship Id="extra" Type="${imageRelationship}" Target="media/extra.png"/></Relationships>`
)
}),
expectation,
"extra"
)
).toThrow("额外");
});
it("拒绝超过当前页面内容区的显示尺寸", () => {
expect(() =>
inspectDocxMediaAcceptance(
fixture(),
{
...expectation,
maximumWidthEmu: 3_047_000
},
"oversized"
)
).toThrow("dimensions");
});
});
@@ -24,6 +24,7 @@ function resource(
kindOrdinal,
altText: `${kind} ${kindOrdinal}`,
caption: `${kind} 图注`,
alignment: "center",
displayWidthPx: 384,
displayHeightPx: 192,
captureX: 0,
@@ -70,12 +71,20 @@ describe("Pandoc DOCX 媒体映射", () => {
"media/media-003.png"
]);
expect(result.map.image[0]).toMatchObject({
binding: "mdtp-media:docx-media-1",
path: "media/media-001.png",
width: "101.600mm",
height: "50.800mm"
});
expect(result.map.mermaid).toHaveLength(1);
expect(result.map.echarts).toHaveLength(1);
expect(result.layout[0]).toMatchObject({
id: "docx-media-1",
binding: "mdtp-media:docx-media-1",
alignment: "center",
widthEmu: 3_657_600,
heightEmu: 1_828_800
});
});
it("拒绝顺序错位、无效 PNG 和图表错误", () => {
@@ -85,6 +85,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "group",
role: "official-classification",
layout: "space-between",
children: [
text("official-secrecy", "秘密"),
text("official-urgency", "特急")
@@ -94,6 +95,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "group",
role: "official-issue-row",
layout: "space-between",
children: [
text("official-number", "示例〔20261号"),
text(
@@ -115,6 +117,7 @@ describe("Pandoc 语义结构投影", () => {
markerSeed: "official"
});
expect(plan.metadataPolicy.author).toBe("suppress");
expect(plan.titlePolicy.firstBodyHeading).toBe("suppress");
expect(plan.prefix[0]).toMatchObject({
kind: "container",
@@ -127,6 +130,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "paragraph",
styleId: "MdOfficialClassification",
layout: "space-between",
segments: ["秘密", "特急"],
separator: "tab"
},
@@ -138,6 +142,7 @@ describe("Pandoc 语义结构投影", () => {
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["示例〔20261号", "签发人:张三"],
separator: "tab"
}
@@ -228,6 +233,9 @@ describe("Pandoc 语义结构投影", () => {
expect(plan).toEqual({
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
@@ -199,6 +199,10 @@ describe("动态 reference.docx", () => {
expect(documentXml).toContain('w:top="907"');
expect(stylesXml).toContain('w:styleId="SourceCode"');
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
const imageCaptionStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="ImageCaption"[\s\S]*?<\/w:style>/u
)?.[0];
expect(imageCaptionStyle).toContain('<w:jc w:val="center"/>');
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(footerXml).toContain('w:jc w:val="center"');
@@ -226,7 +230,7 @@ describe("动态 reference.docx", () => {
"Source Han Serif SC",
"Times New Roman"
],
fontSizePt: 14,
fontSizePt: 12.75,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28,
@@ -246,6 +250,28 @@ describe("动态 reference.docx", () => {
keepWithNext: true
}
},
{
slot: "code-block",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Lucida Console"],
fontSizePt: 9,
paddingPt: {
top: 6,
right: 3,
bottom: 5,
left: 3
},
contentPaddingLeftPt: 6,
borders: {
top: { widthPt: 0.75, color: "#e7eaed", style: "single" },
right: { widthPt: 0.75, color: "#e7eaed", style: "single" },
bottom: { widthPt: 0.75, color: "#e7eaed", style: "single" },
left: { widthPt: 0.75, color: "#e7eaed", style: "single" }
}
}
},
{
slot: "table",
source: "computed-css",
@@ -268,6 +294,36 @@ describe("动态 reference.docx", () => {
}
}
},
{
slot: "table-cell",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimSun"],
borders: {
top: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
right: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
bottom: {
widthPt: 0.85,
color: "#556677",
style: "single"
},
left: {
widthPt: 0.85,
color: "#556677",
style: "single"
}
}
}
},
{
slot: "official-title",
source: "computed-css",
@@ -288,6 +344,20 @@ describe("动态 reference.docx", () => {
fontCandidates: [],
lineSpacing: 1.5
}
},
{
slot: "official-printing-row",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
paddingPt: {
top: 3,
right: 28,
bottom: 3,
left: 28
}
}
}
])
}
@@ -302,22 +372,46 @@ describe("动态 reference.docx", () => {
);
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialTitle"[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
);
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
expect(stylesXml).toContain('w:ascii="Times New Roman"');
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('<w:jc w:val="both"/>');
const sourceCodeStyle = stylesXml.match(
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
)?.[0];
expect(sourceCodeStyle).toContain(
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
);
expect(sourceCodeStyle).toMatch(
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
);
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).toContain(
'w:line="504" w:lineRule="exact"'
'w:line="459" w:lineRule="exact"'
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="Normal"[\s\S]*?<w:rPr>[\s\S]*?<w:sz w:val="26"\/>[\s\S]*?<w:spacing w:val="-5"\/>/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:spacing[^>]*w:before="60"[^>]*w:after="60"/u
);
expect(stylesXml).toMatch(
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:ind[^>]*w:left="560"[^>]*w:right="560"/u
);
expect(stylesXml).not.toContain('w:lineRule="auto"');
expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"');
expect(stylesXml).toMatch(
/<w:tblBorders>[\s\S]*?<w:insideH[^>]*w:sz="7"[^>]*w:color="556677"\/>[\s\S]*?<w:insideV[^>]*w:sz="7"[^>]*w:color="556677"\/>/u
);
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
);
@@ -377,6 +471,8 @@ describe("动态 reference.docx", () => {
expect(documentXml).toContain('w:orient="landscape"');
expect(documentXml).toContain('w:w="16838"');
expect(documentXml).toContain('w:left="1701"');
expect(documentXml).toContain('w:header="723"');
expect(documentXml).toContain('w:footer="751"');
expect(documentXml).toContain('w:start="5"');
expect(documentXml).toContain("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
@@ -390,10 +486,15 @@ describe("动态 reference.docx", () => {
expect(headerXml).toContain('w:pos="13720"');
expect(headerXml).toContain("<w:pBdr>");
expect(headerXml).toContain("<w:bottom");
expect(headerXml).toMatch(
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(headerXml).not.toContain("<w:tbl");
expect(
decoder.decode(entries["word/footer1.xml"])
).not.toContain("<w:tbl");
const footerXml = decoder.decode(entries["word/footer1.xml"]);
expect(footerXml).toMatch(
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
);
expect(footerXml).not.toContain("<w:tbl");
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
3
);
@@ -407,6 +508,133 @@ describe("动态 reference.docx", () => {
});
});
it("封面页码重启时仍使用原生奇偶页脚保持跨客户端外侧位置", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "page-total",
startFrom: 1
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(exportConfig),
semanticDocument: {
schemaVersion: 1,
profile: "project-report",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: [
{
kind: "cover",
nodes: [
{
kind: "text",
role: "project-report-title",
text: "可研报告"
}
],
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
}
]
}
}
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const defaultFooterXml = decoder.decode(entries["word/footer1.xml"]);
const evenFooterXml = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(defaultFooterXml).toContain('w:jc w:val="left"');
expect(evenFooterXml).toContain('w:jc w:val="right"');
expect(defaultFooterXml).toContain(" = ");
expect(defaultFooterXml).toContain(" - 1 ");
expect(defaultFooterXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(defaultFooterXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(defaultFooterXml).not.toContain(" IF ");
expect(defaultFooterXml).not.toContain("<w:tbl");
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
footerCount: 2
});
});
it("独立控制正文首页页眉和页码", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
header: {
...defaultExportConfig.header,
enabled: true,
showOnFirstPage: false,
showDivider: true,
center: {
enabled: true,
content: "${title}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "center",
showOnFirstPage: true
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const regularHeader = decoder.decode(entries["word/header1.xml"]);
const firstHeader = decoder.decode(entries["word/header2.xml"]);
const regularFooter = decoder.decode(entries["word/footer1.xml"]);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(documentXml).toContain("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(2);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
expect(regularHeader).toContain("年度 &lt;报告&gt;");
expect(regularHeader).toContain("<w:bottom");
expect(firstHeader).not.toContain("年度 &lt;报告&gt;");
expect(firstHeader).not.toContain("<w:bottom");
expect(regularFooter).toContain("PAGE");
expect(firstFooter).toContain("PAGE");
});
it("隐藏正文首页页码时生成无内容且无分隔线的首页页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
footer: {
...defaultExportConfig.footer,
showDivider: true,
showOnFirstPage: false
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
expect(firstFooter).not.toContain("PAGE");
expect(firstFooter).not.toContain("<w:top");
});
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
+2 -2
View File
@@ -5,14 +5,14 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
当前阶段包含:
- 56 个标准 Markdown 与结构化文档语义槽位;
- 60 个标准 Markdown 与结构化文档语义槽位;
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
- Chromium/Electron 计算样式快照协议;
- 不绑定具体浏览器实现的计算样式采集器;
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
- CSS 长度、颜色、字体、边框和布局值的通用解析器;
- 56 个语义槽位到 Word 文本、段落、表格、媒体和结构令牌的归一化;
- 60 个语义槽位到 Word 文本、段落、表格、媒体和结构令牌的归一化;
- 封面最小高度、垂直对齐、轮廓线和隐藏字段语义;
- 自动映射、显式覆盖和预设降级配置;
- 样式来源、映射置信度和诊断协议;
@@ -60,7 +60,11 @@ export function readDocxComputedStyle(
borderBottom: style.borderBottom,
borderLeft: style.borderLeft,
width: style.width,
...(style.containingBlockWidth
? { containingBlockWidth: style.containingBlockWidth }
: {}),
maxWidth: style.maxWidth,
minWidth: style.minWidth,
minHeight: style.minHeight,
height: style.height,
breakBefore: style.breakBefore,
@@ -69,7 +73,11 @@ export function readDocxComputedStyle(
display: style.display,
flexDirection: style.flexDirection,
alignItems: style.alignItems,
...(style.alignSelf ? { alignSelf: style.alignSelf } : {}),
justifyContent: style.justifyContent,
...(style.contentPaddingLeft
? { contentPaddingLeft: style.contentPaddingLeft }
: {}),
outline: style.outline,
outlineOffset: style.outlineOffset
};
+71 -2
View File
@@ -463,11 +463,21 @@ function normalizeComputedSlot(
};
}
}
if (widthSlots.has(context.slot) && context.documentWidthPx) {
const containingBlockWidthPt = computed.containingBlockWidth
? parseCssLengthToPt(computed.containingBlockWidth)
: undefined;
const relativeWidthPx =
containingBlockWidthPt !== undefined
? containingBlockWidthPt / 0.75
: context.documentWidthPx;
if (
(widthSlots.has(context.slot) || context.kind === "structure") &&
relativeWidthPx
) {
const widthPt = parseCssLengthToPt(computed.width);
const widthPx = widthPt === undefined ? undefined : widthPt / 0.75;
if (widthPx !== undefined) {
const percent = (widthPx / context.documentWidthPx) * 100;
const percent = (widthPx / relativeWidthPx) * 100;
style.widthPercent = Math.round(
Math.max(0, Math.min(100, percent))
);
@@ -482,6 +492,27 @@ function normalizeComputedSlot(
}
}
}
if (context.kind === "structure" && context.documentWidthPx) {
const percentage = /^([+-]?(?:\d+\.?\d*|\.\d+))%$/u.exec(
computed.minWidth
);
const minimumWidthPercent = percentage
? Number.parseFloat(percentage[1]!)
: (() => {
const minimumWidthPt = parseCssLengthToPt(computed.minWidth);
return minimumWidthPt === undefined
? undefined
: ((minimumWidthPt / 0.75) / context.documentWidthPx) * 100;
})();
if (
minimumWidthPercent !== undefined &&
minimumWidthPercent > 0
) {
style.minimumWidthPercent = roundDocxValue(
Math.min(100, minimumWidthPercent)
);
}
}
const minimumHeightPt = length(
context,
"min-height",
@@ -491,6 +522,15 @@ function normalizeComputedSlot(
if (minimumHeightPt !== undefined && minimumHeightPt > 0) {
style.minimumHeightPt = minimumHeightPt;
}
const contentPaddingLeftPt = computed.contentPaddingLeft
? length(context, "content-padding-left", computed.contentPaddingLeft, {
allowAuto: true,
nonNegative: true
})
: undefined;
if (contentPaddingLeftPt !== undefined && contentPaddingLeftPt > 0) {
style.contentPaddingLeftPt = contentPaddingLeftPt;
}
if (computed.display === "none") {
style.hidden = true;
}
@@ -512,6 +552,35 @@ function normalizeComputedSlot(
if (resolved) {
style.verticalAlignment = resolved;
}
const childAlignment = {
"flex-start": "left",
start: "left",
center: "center",
"flex-end": "right",
end: "right",
stretch: "stretch"
} as const;
const resolvedChildAlignment =
childAlignment[
computed.alignItems as keyof typeof childAlignment
];
if (resolvedChildAlignment) {
style.childAlignment = resolvedChildAlignment;
}
}
const selfAlignment = {
"flex-start": "left",
start: "left",
center: "center",
"flex-end": "right",
end: "right",
stretch: "stretch"
} as const;
const resolvedSelfAlignment = computed.alignSelf
? selfAlignment[computed.alignSelf as keyof typeof selfAlignment]
: undefined;
if (resolvedSelfAlignment) {
style.selfAlignment = resolvedSelfAlignment;
}
if (computed.display === "flex" || computed.display === "grid") {
diagnostic(context, {
+3 -3
View File
@@ -13,12 +13,12 @@ const transparentPixel =
export function createDocxStyleProbeMarkup(): string {
return `
<article id="write" ${attribute("document")}>
<h1 ${attribute("heading-1")}></h1>
<header class="doc-metadata">
<h1 class="doc-metadata-title" ${attribute("document-title")}></h1>
<p class="doc-author" ${attribute("document-author")}></p>
</header>
<section class="docx-probe-markdown">
<h1 ${attribute("heading-1")}></h1>
<h2 ${attribute("heading-2")}></h2>
<h3 ${attribute("heading-3")}></h3>
<h4 ${attribute("heading-4")}></h4>
@@ -56,8 +56,8 @@ export function createDocxStyleProbeMarkup(): string {
</div>
</header>
<h1 class="doc-title" ${attribute("official-title")}></h1>
<section class="doc-signature" ${attribute("official-signature")}><p></p><time>2026730</time></section>
<footer class="doc-edition" ${attribute("official-edition")}><p class="doc-copy-to"></p><div class="doc-printing-row"><span></span><time>2026730</time></div></footer>
<section class="doc-signature" ${attribute("official-signature")}><p class="doc-signature-issuer" ${attribute("official-signature-issuer")}></p><time class="doc-signature-date" ${attribute("official-signature-date")}>2026730</time></section>
<footer class="doc-edition" ${attribute("official-edition")}><p class="doc-copy-to" ${attribute("official-copy-to")}></p><div class="doc-printing-row" ${attribute("official-printing-row")}><span></span><time>2026730</time></div></footer>
</section>
<section class="docx-probe-briefing">
<header class="doc-masthead doc-masthead-briefing" ${attribute("briefing-masthead")}>
+42
View File
@@ -75,6 +75,7 @@ const computedProperties = [
"borderLeft",
"width",
"maxWidth",
"minWidth",
"minHeight",
"height",
"breakBefore",
@@ -83,6 +84,7 @@ const computedProperties = [
"display",
"flexDirection",
"alignItems",
"alignSelf",
"justifyContent",
"outline",
"outlineOffset"
@@ -104,6 +106,23 @@ body {
margin: 0;
padding: 0;
}
#write pre.md-fences > code,
#write .md-code-pagination-chunk > code {
display: block;
min-width: 0;
max-width: 100%;
padding: 0;
padding-left: 8px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
`;
export function createDocxThemeStyleCaptureScript(
@@ -190,6 +209,29 @@ export function createDocxThemeStyleCaptureScript(
for (const property of payload.properties) {
computed[property] = style[property];
}
const contentElement = element.firstElementChild;
if (contentElement) {
computed.contentPaddingLeft =
probeWindow.getComputedStyle(contentElement).paddingLeft;
}
const containingBlock = element.parentElement;
if (containingBlock) {
const containingStyle = probeWindow.getComputedStyle(
containingBlock
);
const containingRect = containingBlock.getBoundingClientRect();
const horizontalInsets = [
containingStyle.borderLeftWidth,
containingStyle.borderRightWidth,
containingStyle.paddingLeft,
containingStyle.paddingRight
].reduce(
(total, value) => total + (Number.parseFloat(value) || 0),
0
);
computed.containingBlockWidth =
Math.max(0, containingRect.width - horizontalInsets) + "px";
}
return {
slot: definition.name,
matched: true,
+8
View File
@@ -36,7 +36,11 @@ export const DOCX_STYLE_SLOT_NAMES = [
"official-signatory",
"official-title",
"official-signature",
"official-signature-issuer",
"official-signature-date",
"official-edition",
"official-copy-to",
"official-printing-row",
"briefing-masthead",
"briefing-meta",
"briefing-title",
@@ -134,7 +138,11 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
"official-signatory": "structure",
"official-title": "structure",
"official-signature": "structure",
"official-signature-issuer": "structure",
"official-signature-date": "structure",
"official-edition": "structure",
"official-copy-to": "structure",
"official-printing-row": "structure",
"briefing-masthead": "structure",
"briefing-meta": "structure",
"briefing-title": "structure",
@@ -29,7 +29,9 @@ export const docxComputedStyleSchema = z.object({
borderBottom: cssValueSchema,
borderLeft: cssValueSchema,
width: cssValueSchema,
containingBlockWidth: cssValueSchema.optional(),
maxWidth: cssValueSchema,
minWidth: cssValueSchema,
minHeight: cssValueSchema,
height: cssValueSchema,
breakBefore: cssValueSchema,
@@ -38,7 +40,9 @@ export const docxComputedStyleSchema = z.object({
display: cssValueSchema,
flexDirection: cssValueSchema,
alignItems: cssValueSchema,
alignSelf: cssValueSchema.optional(),
justifyContent: cssValueSchema,
contentPaddingLeft: cssValueSchema.optional(),
outline: cssValueSchema,
outlineOffset: cssValueSchema
});
+8
View File
@@ -106,10 +106,18 @@ export const docxSlotStyleTokenSchema = z.object({
})
.optional(),
widthPercent: z.number().min(0).max(100).optional(),
minimumWidthPercent: z.number().min(0).max(100).optional(),
minimumHeightPt: z.number().min(0).max(10000).optional(),
contentPaddingLeftPt: z.number().min(0).max(1000).optional(),
verticalAlignment: z
.enum(["top", "center", "bottom"])
.optional(),
childAlignment: z
.enum(["left", "center", "right", "stretch"])
.optional(),
selfAlignment: z
.enum(["left", "center", "right", "stretch"])
.optional(),
hidden: z.boolean().optional(),
pageBreakBefore: z.boolean().optional(),
pageBreakAfter: z.boolean().optional(),
@@ -105,6 +105,7 @@ describe("DOCX 主题引擎契约", () => {
borderLeft: "0px none rgb(0, 0, 0)",
width: "100px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -40,6 +40,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 34, 51)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -203,12 +204,40 @@ describe("DOCX 主题令牌归一化", () => {
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
});
it("按父级内容区归一化右置百分比结构容器", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"official-signature": {
width: "332.8px",
containingBlockWidth: "640px",
marginLeft: "307.2px",
marginRight: "0px",
textAlign: "center"
}
}),
config: {
mode: "auto",
basePreset: "official",
overrides: {},
legacyPreset: false
}
});
expect(findSlot(tokens, "official-signature").style).toMatchObject({
widthPercent: 52,
leftIndentPt: 230.4,
rightIndentPt: 0,
alignment: "center"
});
});
it("保留封面高度、垂直对齐、轮廓线和隐藏字段语义", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"tender-cover": {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "220mm",
borderBottom: "0px none rgb(17, 34, 51)",
@@ -216,7 +245,9 @@ describe("DOCX 主题令牌归一化", () => {
outlineOffset: "-16px"
},
"tender-bidder": {
display: "none"
display: "none",
alignSelf: "flex-end",
contentPaddingLeft: "8px"
}
}),
config: {
@@ -231,6 +262,7 @@ describe("DOCX 主题令牌归一化", () => {
expect(cover.style).toMatchObject({
minimumHeightPt: 623.622,
verticalAlignment: "center",
childAlignment: "center",
borders: {
top: {
widthPt: 0.75,
@@ -239,7 +271,11 @@ describe("DOCX 主题令牌归一化", () => {
}
}
});
expect(findSlot(tokens, "tender-bidder").style.hidden).toBe(true);
expect(findSlot(tokens, "tender-bidder").style).toMatchObject({
hidden: true,
selfAlignment: "right",
contentPaddingLeftPt: 6
});
expect(
tokens.diagnostics.some(
(entry) =>
@@ -250,6 +286,31 @@ describe("DOCX 主题令牌归一化", () => {
).toBe(true);
});
it("保留结构槽位的实际宽度以映射 Flex 子项位置", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
document: {
width: "640px",
paddingLeft: "8px",
paddingRight: "8px"
},
"project-report-owner": {
minWidth: "70%"
}
}),
config: {
mode: "auto",
basePreset: "formal",
overrides: {},
legacyPreset: false
}
});
expect(
findSlot(tokens, "project-report-owner").style.minimumWidthPercent
).toBe(70);
});
it("将超出 Word 能力的装饰边收敛并生成诊断", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
@@ -38,6 +38,7 @@ const computedStyle: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 17, 17)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "28px",
breakBefore: "auto",
@@ -73,6 +74,9 @@ describe("DOCX 标准样式探针", () => {
expect(new Set(slots)).toEqual(
new Set(DOCX_STYLE_SLOT_NAMES)
);
expect(markup).toMatch(
/^<article id="write"[^>]*>\s*<h1 data-docx-slot="heading-1">/u
);
});
it("拒绝缺失和重复槽位的探针", () => {
@@ -43,6 +43,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 17, 17)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -79,6 +80,10 @@ describe("DOCX 主题样式浏览器运行时", () => {
themeCss: '#write::before { content: "</style><script>x</script>"; }'
});
expect(script).toContain("theme.textContent");
expect(script).toContain("style[property]");
expect(script).toContain("contentPaddingLeft");
expect(script).toContain("padding-left: 8px");
expect(script).toContain("#write pre.md-fences \\u003e code");
expect(script).toContain("frame.remove()");
expect(script).not.toContain("</style><script>x</script>");
});
@@ -5,6 +5,7 @@ import {
MAXIMUM_DOCX_RESOURCE_COUNT,
type DocxMediaCapturePlan,
type DocxMediaCaptureTarget,
type DocxMediaAlignment,
type DocxMediaKind,
type PagedDocumentPayload
} from "@md-to-pdf/core";
@@ -93,6 +94,33 @@ function fitElementToContent(
return element.getBoundingClientRect();
}
function getMediaAlignment(
article: HTMLElement,
rect: DOMRect
): DocxMediaAlignment {
const articleRect = article.getBoundingClientRect();
const articleWidth = finitePositive(articleRect.width, rect.width);
if (rect.width >= articleWidth - 2) {
return "center";
}
const leftScore = Math.abs(rect.left - articleRect.left);
const centerScore = Math.abs(
(rect.left + rect.right) / 2 -
(articleRect.left + articleRect.right) / 2
);
const rightScore = Math.abs(articleRect.right - rect.right);
const minimumScore = Math.min(
leftScore,
centerScore,
rightScore
);
if (centerScore <= minimumScore + 2) {
return "center";
}
return leftScore <= rightScore ? "left" : "right";
}
function getRasterScale(width: number, height: number) {
return Math.min(
DOCX_MEDIA_RASTER_SCALE,
@@ -179,6 +207,7 @@ export function collectDocxMediaCaptureTargets(
...(getCaption(element)
? { caption: getCaption(element) }
: {}),
alignment: getMediaAlignment(article, rect),
displayWidthPx: width,
displayHeightPx: height,
captureX,
+3
View File
@@ -12,6 +12,9 @@ export * from "./mermaid-static-image.js";
export * from "./paged-break-token.js";
export * from "./paged-document-runtime.js";
export * from "./paged-preview.js";
export * from "./paged-page-sequence.js";
export * from "./semantic-cover-fit.js";
export * from "./paged-page-decorations.js";
export * from "./paged-render-target.js";
export * from "./pdf-document-links.js";
export * from "./preview-styles.js";
@@ -44,13 +44,13 @@ import {
documentBaseCss,
documentGeometryCss,
documentInteractionCss,
formatPageNumber,
resolvePageNumberAlignment,
shouldRenderPageNumber,
type PagedPreviewPayload
} from "./paged-preview.js";
import { constrainSemanticCoversToPage } from "./semantic-cover-fit.js";
import type { PagedRenderTarget } from "./paged-render-target.js";
import { preparePdfDocumentLinks } from "./pdf-document-links.js";
import { classifyPagedPages } from "./paged-page-sequence.js";
import { applyPagedPageDecorations } from "./paged-page-decorations.js";
export type {
PagedDocumentRenderResult,
@@ -155,48 +155,6 @@ function mountMeasurementContainer(
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
if (
!shouldRenderPageNumber(
payload.exportConfig.footer,
pageIndex
)
) {
continue;
}
const alignment = resolvePageNumberAlignment(
payload.exportConfig.footer,
pageIndex
);
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
@@ -215,6 +173,7 @@ function createPagedRenderIdentity(
mermaidOutput: options.mermaidOutput,
themeCss: payload.themeCss,
exportConfig: payload.exportConfig,
semanticDocument: payload.semanticDocument,
metadata: payload.metadata,
features: payload.features
});
@@ -676,7 +635,8 @@ export class PagedDocumentRuntime {
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image")
content.querySelector("img.md-document-image") ||
content.querySelector('[data-semantic-region="cover"]')
) {
const measurement = mountMeasurementContainer(
documentRef,
@@ -687,6 +647,10 @@ export class PagedDocumentRuntime {
);
try {
await documentRef.fonts.ready;
constrainSemanticCoversToPage(
measurement.host,
getPageContentDimensions(payload.exportConfig).height
);
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
@@ -902,7 +866,8 @@ export class PagedDocumentRuntime {
}
const finalizeStartedAt = performance.now();
applyPageNumbers(this.root, payload, pageCount);
const pageSequence = classifyPagedPages(this.root, payload);
applyPagedPageDecorations(this.root, payload, pageSequence);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
@@ -0,0 +1,107 @@
import {
formatPageNumber,
resolvePageNumberAlignment,
type PagedPreviewPayload
} from "./paged-preview.js";
import type { PagedPageSequence } from "./paged-page-sequence.js";
const alignments = ["left", "center", "right"] as const;
function setMarginBoxesVisible(
page: HTMLElement,
position: "top" | "bottom",
visible: boolean
) {
for (const alignment of alignments) {
const box = page.querySelector<HTMLElement>(
`.pagedjs_margin-${position}-${alignment}`
);
if (!box) {
continue;
}
if (visible) {
box.style.removeProperty("visibility");
} else {
box.style.setProperty("visibility", "hidden", "important");
}
}
}
function clearInjectedPageNumbers(page: HTMLElement) {
for (const alignment of alignments) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = "";
content.removeAttribute("data-page-number-rendered");
content.removeAttribute("data-page-number-alignment");
}
}
export function applyPagedPageDecorations(
container: ParentNode,
payload: PagedPreviewPayload,
sequence: PagedPageSequence
) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
const pageNumberConfig = {
...payload.exportConfig.footer,
startFrom: sequence.bodyPageNumberStart
};
for (const state of sequence.pages) {
const page = pages[state.physicalPageIndex];
if (!page) {
continue;
}
clearInjectedPageNumbers(page);
const isCover = state.kind === "cover";
const isBodyFirst = state.kind === "body-first";
const headerVisible =
payload.exportConfig.header.enabled &&
!isCover &&
(!isBodyFirst || payload.exportConfig.header.showOnFirstPage);
const footerVisible =
payload.exportConfig.footer.enabled &&
!isCover &&
(!isBodyFirst || payload.exportConfig.footer.showOnFirstPage);
setMarginBoxesVisible(page, "top", headerVisible);
setMarginBoxesVisible(page, "bottom", footerVisible);
page.dataset.headerVisible = String(headerVisible);
page.dataset.footerVisible = String(footerVisible);
if (
!footerVisible ||
state.bodyPageIndex === undefined ||
state.pageNumber === undefined
) {
continue;
}
const alignment = resolvePageNumberAlignment(
pageNumberConfig,
state.bodyPageIndex
);
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
pageNumberConfig,
state.bodyPageIndex,
sequence.bodyPageCount
);
content.setAttribute("data-page-number-rendered", "true");
content.setAttribute("data-page-number-alignment", alignment);
}
}
@@ -0,0 +1,110 @@
import type {
PagedDocumentPayload,
SemanticDocumentSectionIntent
} from "@md-to-pdf/core";
export type PagedPageKind = "cover" | "body-first" | "body-rest";
export interface PagedPageState {
physicalPageIndex: number;
kind: PagedPageKind;
bodyPageIndex?: number;
pageNumber?: number;
}
export interface PagedPageSequence {
pages: PagedPageState[];
physicalPageCount: number;
bodyPageCount: number;
bodyPageNumberStart: number;
}
function findCoverSectionIntent(
payload: Pick<PagedDocumentPayload, "semanticDocument">
): SemanticDocumentSectionIntent | undefined {
return payload.semanticDocument.regions.find(
(region) => region.kind === "cover" && region.section
)?.section;
}
export function createPagedPageSequence(
physicalPageCount: number,
coverPageIndexes: ReadonlySet<number>,
bodyPageNumberStart: number
): PagedPageSequence {
const pages: PagedPageState[] = [];
let bodyPageIndex = 0;
for (
let physicalPageIndex = 0;
physicalPageIndex < physicalPageCount;
physicalPageIndex += 1
) {
if (coverPageIndexes.has(physicalPageIndex)) {
pages.push({ physicalPageIndex, kind: "cover" });
continue;
}
pages.push({
physicalPageIndex,
kind: bodyPageIndex === 0 ? "body-first" : "body-rest",
bodyPageIndex,
pageNumber: bodyPageNumberStart + bodyPageIndex
});
bodyPageIndex += 1;
}
return {
pages,
physicalPageCount,
bodyPageCount: bodyPageIndex,
bodyPageNumberStart
};
}
export function classifyPagedPages(
container: ParentNode,
payload: Pick<
PagedDocumentPayload,
"semanticDocument" | "exportConfig"
>
): PagedPageSequence {
const pageElements = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
const coverIntent = findCoverSectionIntent(payload);
const coverPageIndexes = new Set<number>();
if (coverIntent) {
for (const [pageIndex, page] of pageElements.entries()) {
if (page.querySelector('[data-semantic-region="cover"]')) {
coverPageIndexes.add(pageIndex);
}
}
}
const sequence = createPagedPageSequence(
pageElements.length,
coverPageIndexes,
coverIntent?.followingPageNumberStart ??
payload.exportConfig.footer.startFrom
);
for (const state of sequence.pages) {
const page = pageElements[state.physicalPageIndex];
if (!page) {
continue;
}
page.dataset.pageKind = state.kind;
page.dataset.physicalPageIndex = String(state.physicalPageIndex);
if (state.bodyPageIndex === undefined) {
delete page.dataset.bodyPageIndex;
delete page.dataset.pageNumber;
} else {
page.dataset.bodyPageIndex = String(state.bodyPageIndex);
page.dataset.pageNumber = String(state.pageNumber);
}
}
return sequence;
}
@@ -162,6 +162,16 @@ body,
background: transparent !important;
}
#write [data-semantic-break-after="next-page"] {
break-after: page !important;
page-break-after: always !important;
}
#write [data-semantic-region="cover"][data-semantic-break-after="next-page"] {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
#write {
width: 100% !important;
max-width: none !important;
@@ -283,6 +293,7 @@ function marginBox(
fontFamily: string;
fontSize: string;
height: string;
pageMargin: string;
showDivider: boolean;
}
) {
@@ -293,6 +304,10 @@ function marginBox(
? "border-top: 0.2mm solid currentColor;"
: "";
const verticalAlignment = position === "top" ? "bottom" : "top";
const verticalOffset =
position === "top"
? `calc(${options.pageMargin} - ${options.height} - ${options.fontSize})`
: `calc(${options.height} - ${options.fontSize})`;
return `
@${position}-${alignment} {
@@ -305,6 +320,7 @@ function marginBox(
line-height: 1.25;
text-align: ${alignment};
vertical-align: ${verticalAlignment};
transform: translateY(${verticalOffset});
${border}
}`;
}
@@ -322,6 +338,7 @@ function buildHeaderCss(
fontFamily: config.header.fontFamily,
fontSize: config.header.fontSize,
height: config.header.height,
pageMargin: config.paper.margins.top,
showDivider: config.header.showDivider
};
@@ -351,6 +368,7 @@ function buildFooterCss(config: ExportConfig) {
fontFamily: config.footer.fontFamily,
fontSize: config.footer.fontSize,
height: config.footer.height,
pageMargin: config.paper.margins.bottom,
showDivider: config.footer.showDivider
};
@@ -0,0 +1,33 @@
const COVER_HEIGHT_EPSILON_PX = 0.5;
export const semanticCoverSelector =
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]';
export function constrainSemanticCoversToPage(
root: ParentNode,
pageContentHeightPx: number
) {
if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) {
return 0;
}
let constrainedCount = 0;
for (const cover of Array.from(
root.querySelectorAll<HTMLElement>(semanticCoverSelector)
)) {
const height = cover.getBoundingClientRect().height;
if (
!Number.isFinite(height) ||
height <= pageContentHeightPx + COVER_HEIGHT_EPSILON_PX
) {
continue;
}
const constrainedHeight = `${pageContentHeightPx}px`;
cover.style.boxSizing = "border-box";
cover.style.height = constrainedHeight;
cover.style.minHeight = constrainedHeight;
cover.style.maxHeight = constrainedHeight;
cover.dataset.semanticCoverFit = "constrained";
constrainedCount += 1;
}
return constrainedCount;
}
@@ -30,17 +30,32 @@ describe("DOCX 媒体捕获计划", () => {
"img, svg"
)
);
const article = document.querySelector<HTMLElement>("#write")!;
article.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 700,
bottom: 900,
width: 700,
height: 900,
toJSON: () => ({})
}) as DOMRect;
const horizontalPositions = [0, 200, 400];
media.forEach((element, index) => {
const left = horizontalPositions[index]!;
element.getBoundingClientRect = () =>
({
x: 10,
x: left,
y: 20 + index * 100,
left: 10,
left,
top: 20 + index * 100,
right: 650,
bottom: 380 + index * 100,
width: 640,
height: 360,
right: left + 300,
bottom: 170 + index * 100,
width: 300,
height: 150,
toJSON: () => ({})
}) as DOMRect;
});
@@ -52,12 +67,13 @@ describe("DOCX 媒体捕获计划", () => {
expect(
targets.map(
({ id, kind, kindOrdinal, altText, caption }) => ({
({ id, kind, kindOrdinal, altText, caption, alignment }) => ({
id,
kind,
kindOrdinal,
altText,
caption
caption,
alignment
})
)
).toEqual([
@@ -66,21 +82,24 @@ describe("DOCX 媒体捕获计划", () => {
kind: "image",
kindOrdinal: 1,
altText: "架构截图",
caption: "系统架构"
caption: "系统架构",
alignment: "left"
},
{
id: "docx-media-2",
kind: "mermaid",
kindOrdinal: 1,
altText: "处理流程",
caption: undefined
caption: undefined,
alignment: "center"
},
{
id: "docx-media-3",
kind: "echarts",
kindOrdinal: 1,
altText: "年度收入",
caption: "收入趋势"
caption: "收入趋势",
alignment: "right"
}
]);
expect(
@@ -96,6 +115,19 @@ describe("DOCX 媒体捕获计划", () => {
</article>
`;
const image = document.querySelector("img")!;
const article = document.querySelector<HTMLElement>("#write")!;
article.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 800,
bottom: 900,
width: 800,
height: 900,
toJSON: () => ({})
}) as DOMRect;
image.getBoundingClientRect = () =>
({
x: 0,
@@ -115,6 +147,8 @@ describe("DOCX 媒体捕获计划", () => {
});
expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
expect(target?.displayHeightPx).toBeLessThanOrEqual(900);
expect(target?.alignment).toBe("center");
expect(
(target?.captureWidthPx ?? 0) *
(target?.rasterScale ?? 0)
@@ -0,0 +1,123 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { applyPagedPageDecorations } from "../src/paged-page-decorations.js";
import { createPagedPageSequence } from "../src/paged-page-sequence.js";
import type { PagedPreviewPayload } from "../src/paged-preview.js";
function createPage(content: string) {
return `
<div class="pagedjs_page">
${content}
<div class="pagedjs_margin-top-left"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin-top-center"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin-top-right"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin-bottom-left"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin-bottom-center"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin-bottom-right"><div class="pagedjs_margin-content"></div></div>
</div>
`;
}
function createPayload(): PagedPreviewPayload {
return {
articleHtml: '<article id="write"></article>',
fileName: "封面测试.md",
metadata: {
title: "封面测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
themeCss: "",
exportConfig: {
...defaultExportConfig,
header: {
...defaultExportConfig.header,
enabled: true,
showOnFirstPage: true
},
footer: {
...defaultExportConfig.footer,
alignment: "outer",
showOnFirstPage: false
}
}
};
}
describe("分页页面装饰", () => {
it("隐藏封面装饰并按正文页序计算首页和外侧页码", () => {
const root = document.createElement("div");
root.innerHTML = [
createPage("封面"),
createPage("正文首页"),
createPage("正文第二页")
].join("");
const payload = createPayload();
const sequence = createPagedPageSequence(3, new Set([0]), 1);
applyPagedPageDecorations(root, payload, sequence);
const pages = root.querySelectorAll<HTMLElement>(".pagedjs_page");
expect(pages[0]?.dataset.headerVisible).toBe("false");
expect(pages[0]?.dataset.footerVisible).toBe("false");
expect(
pages[0]?.querySelector<HTMLElement>(".pagedjs_margin-top-left")
?.style.visibility
).toBe("hidden");
expect(pages[1]?.dataset.headerVisible).toBe("true");
expect(pages[1]?.dataset.footerVisible).toBe("false");
expect(pages[2]?.dataset.footerVisible).toBe("true");
const pageNumber = pages[2]?.querySelector<HTMLElement>(
".pagedjs_margin-bottom-left .pagedjs_margin-content"
);
expect(pageNumber?.textContent).toBe("2 / 2");
expect(pageNumber?.dataset.pageNumberAlignment).toBe("left");
});
it("允许正文首页独立隐藏页眉但保留页码", () => {
const root = document.createElement("div");
root.innerHTML = createPage("正文首页");
const payload = createPayload();
payload.exportConfig = {
...payload.exportConfig,
header: {
...payload.exportConfig.header,
showOnFirstPage: false
},
footer: {
...payload.exportConfig.footer,
alignment: "center",
showOnFirstPage: true,
startFrom: 5
}
};
applyPagedPageDecorations(
root,
payload,
createPagedPageSequence(1, new Set(), 5)
);
const page = root.querySelector<HTMLElement>(".pagedjs_page");
expect(page?.dataset.headerVisible).toBe("false");
expect(page?.dataset.footerVisible).toBe("true");
expect(
page?.querySelector<HTMLElement>(
".pagedjs_margin-bottom-center .pagedjs_margin-content"
)?.textContent
).toBe("5 / 1");
});
});
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
classifyPagedPages,
createPagedPageSequence
} from "../src/paged-page-sequence.js";
function semanticDocument(withCover: boolean) {
return {
schemaVersion: 1 as const,
titlePolicy: {
metadataTitle: "suppress" as const,
firstBodyHeading: "keep" as const
},
regions: withCover
? [
{
kind: "cover" as const,
nodes: [
{
kind: "text" as const,
role: "project-report-title" as const,
text: "封面"
}
],
section: {
headerFooter: "none" as const,
pageNumber: "hidden" as const,
breakAfter: "next-page" as const,
followingPageNumberStart: 1
}
}
]
: []
};
}
describe("分页页面状态", () => {
it("将封面排除在正文页序之外", () => {
expect(createPagedPageSequence(4, new Set([0]), 1)).toEqual({
physicalPageCount: 4,
bodyPageCount: 3,
bodyPageNumberStart: 1,
pages: [
{ physicalPageIndex: 0, kind: "cover" },
{
physicalPageIndex: 1,
kind: "body-first",
bodyPageIndex: 0,
pageNumber: 1
},
{
physicalPageIndex: 2,
kind: "body-rest",
bodyPageIndex: 1,
pageNumber: 2
},
{
physicalPageIndex: 3,
kind: "body-rest",
bodyPageIndex: 2,
pageNumber: 3
}
]
});
});
it("无封面时将物理首页识别为正文首页", () => {
const sequence = createPagedPageSequence(2, new Set(), 5);
expect(sequence.bodyPageCount).toBe(2);
expect(sequence.pages[0]).toMatchObject({
kind: "body-first",
bodyPageIndex: 0,
pageNumber: 5
});
});
it("根据语义封面和分页 DOM 标记页面", () => {
const root = document.createElement("div");
root.innerHTML = `
<div class="pagedjs_page"><div data-semantic-region="cover"></div></div>
<div class="pagedjs_page"><p></p></div>
<div class="pagedjs_page"><p></p></div>
`;
const sequence = classifyPagedPages(root, {
semanticDocument: semanticDocument(true),
exportConfig: {
...defaultExportConfig,
footer: { ...defaultExportConfig.footer, startFrom: 8 }
}
});
const pages = root.querySelectorAll<HTMLElement>(".pagedjs_page");
expect(sequence.bodyPageCount).toBe(2);
expect(pages[0]?.dataset.pageKind).toBe("cover");
expect(pages[0]?.dataset.pageNumber).toBeUndefined();
expect(pages[1]?.dataset.pageKind).toBe("body-first");
expect(pages[1]?.dataset.pageNumber).toBe("1");
expect(pages[2]?.dataset.pageNumber).toBe("2");
});
it("没有封面语义时忽略孤立的封面 DOM 标记", () => {
const root = document.createElement("div");
root.innerHTML = `
<div class="pagedjs_page"><div data-semantic-region="cover"></div></div>
`;
const sequence = classifyPagedPages(root, {
semanticDocument: semanticDocument(false),
exportConfig: defaultExportConfig
});
expect(sequence.pages[0]).toMatchObject({
kind: "body-first",
pageNumber: 1
});
});
});
@@ -26,6 +26,14 @@ const payload: PagedPreviewPayload = {
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
themeCss: "#write { color: #333; }",
exportConfig: defaultExportConfig
@@ -94,6 +102,9 @@ describe("分页预览协议", () => {
);
expect(css).toContain('content: "内网团队"');
expect(css).toContain("border-bottom: 0.2mm solid currentColor");
expect(css).toContain(
"transform: translateY(calc(16mm - 8mm - 3mm))"
);
});
it("支持自定义页码模板和起始页码", () => {
@@ -192,6 +203,40 @@ describe("分页预览协议", () => {
);
});
it("由语义文档强制封面结束后换页", () => {
expect(documentGeometryCss).toContain(
'#write [data-semantic-break-after="next-page"]'
);
expect(documentGeometryCss).toContain(
"break-after: page !important"
);
expect(documentGeometryCss).toContain(
"page-break-after: always !important"
);
expect(documentGeometryCss).toContain(
'[data-semantic-region="cover"]'
);
expect(documentGeometryCss).toContain(
"break-inside: avoid !important"
);
});
it("使用标准 Letter 横向尺寸生成分页规则", () => {
const css = buildPagedMediaCss(
{
...defaultExportConfig,
paper: {
...defaultExportConfig.paper,
format: "Letter",
orientation: "landscape"
}
},
payload
);
expect(css).toContain("size: 279.4mm 215.9mm;");
});
it("避免 Typora 围栏容器重复应用行内代码盒模型", () => {
expect(documentBaseCss).toContain(
"#write pre.md-fences > code"
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { constrainSemanticCoversToPage } from "../src/semantic-cover-fit.js";
function createCover(height: number) {
const root = document.createElement("div");
root.innerHTML = `
<header
data-semantic-region="cover"
data-semantic-break-after="next-page"
></header>
`;
const cover = root.querySelector<HTMLElement>("header")!;
cover.getBoundingClientRect = () =>
({ height } as DOMRect);
return { root, cover };
}
describe("语义封面页面适配", () => {
it("只将超过横向页面内容区的封面收束为单页高度", () => {
const { root, cover } = createCover(900);
expect(constrainSemanticCoversToPage(root, 680)).toBe(1);
expect(cover.style.boxSizing).toBe("border-box");
expect(cover.style.height).toBe("680px");
expect(cover.style.minHeight).toBe("680px");
expect(cover.style.maxHeight).toBe("680px");
expect(cover.dataset.semanticCoverFit).toBe("constrained");
});
it("纵向页面可以容纳主题封面时保持主题原始高度", () => {
const { root, cover } = createCover(680);
expect(constrainSemanticCoversToPage(root, 900)).toBe(0);
expect(cover.getAttribute("style")).toBeNull();
expect(cover.dataset.semanticCoverFit).toBeUndefined();
});
});
@@ -186,13 +186,25 @@ const htmlRoles: Readonly<Record<SemanticDocumentRole, HtmlRole>> = {
}
};
function renderNode(node: SemanticDocumentNode): string {
function renderNode(
node: SemanticDocumentNode,
options: {
regionKind?: "cover";
breakAfter?: "next-page";
} = {}
): string {
const role = htmlRoles[node.role];
const content =
node.kind === "group"
? node.children.map(renderNode).join("")
? node.children.map((child) => renderNode(child)).join("")
: `${node.label ? `<span>${escapeHtml(node.label)}</span>` : ""}${escapeHtml(node.text)}`;
return `<${role.tagName} class="${role.className}">${content}</${role.tagName}>`;
const regionAttribute = options.regionKind
? ` data-semantic-region="${options.regionKind}"`
: "";
const breakAttribute = options.breakAfter
? ` data-semantic-break-after="${options.breakAfter}"`
: "";
return `<${role.tagName} class="${role.className}"${regionAttribute}${breakAttribute}>${content}</${role.tagName}>`;
}
export function renderDocumentStructure(
@@ -206,13 +218,24 @@ export function renderDocumentStructure(
(region) =>
region.kind === "cover" || region.kind === "prefix"
)
.flatMap((region) => region.nodes)
.map(renderNode)
.flatMap((region) =>
region.nodes.map((node, index) =>
renderNode(node, {
...(region.kind === "cover"
? { regionKind: "cover" as const }
: {}),
...(index === region.nodes.length - 1 &&
region.section?.breakAfter
? { breakAfter: region.section.breakAfter }
: {})
})
)
)
.join("");
const suffixHtml = model.regions
.filter((region) => region.kind === "suffix")
.flatMap((region) => region.nodes)
.map(renderNode)
.map((node) => renderNode(node))
.join("");
return {
profile: model.profile,
@@ -199,6 +199,12 @@ document:
expect(projectReport.articleHtml).toContain(
'class="doc-cover doc-cover-project-report"'
);
expect(projectReport.articleHtml).toContain(
'data-semantic-region="cover"'
);
expect(projectReport.articleHtml).toContain(
'data-semantic-break-after="next-page"'
);
expect(tender.articleHtml).toContain(
'class="doc-cover doc-cover-tender"'
);
@@ -41,7 +41,8 @@ function text(
function group(
role: SemanticDocumentRole,
children: readonly (SemanticDocumentNode | undefined)[]
children: readonly (SemanticDocumentNode | undefined)[],
layout?: SemanticDocumentGroupNode["layout"]
): SemanticDocumentGroupNode | undefined {
const resolved = children.filter(
(child): child is SemanticDocumentNode => child !== undefined
@@ -50,6 +51,7 @@ function group(
? {
kind: "group",
role,
...(layout ? { layout } : {}),
children: resolved
}
: undefined;
@@ -89,11 +91,11 @@ function officialRegions(
const classification = group("official-classification", [
text("official-secrecy", document.secrecy),
text("official-urgency", document.urgency)
]);
], "space-between");
const issueRow = group("official-issue-row", [
text("official-number", document.number),
text("official-signatory", document.signatory, "签发人:")
]);
], "space-between");
const masthead = group("official-masthead", [
classification,
text("official-issuer", document.issuer),
@@ -109,7 +111,7 @@ function officialRegions(
"official-printing-date",
document.date ? `${document.date}印发` : undefined
)
]);
], "space-between");
const edition = group("official-edition", [
text(
"official-copy-to",
@@ -138,7 +140,7 @@ function briefingRegions(
text("briefing-publisher", document.publisher),
text("briefing-signatory", document.signatory, "签发:"),
text("briefing-date", document.date)
]);
], "space-between");
const masthead = group("briefing-masthead", [
text("briefing-masthead-text", document.masthead),
details
@@ -57,6 +57,9 @@ describe("统一语义文档模型", () => {
"suffix"
]);
expect(JSON.stringify(model)).toContain("official-masthead");
expect(JSON.stringify(model)).toContain(
'"layout":"space-between"'
);
expect(JSON.stringify(model)).toContain("抄送:");
expect(JSON.stringify(model)).toContain("2026年7月30日印发");
expect(() => semanticDocumentModelSchema.parse(model))
+93
View File
@@ -0,0 +1,93 @@
import fs from "node:fs";
import { serialize } from "node:v8";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { createPdfDocumentSnapshot } from "../packages/document-visual-diff/dist/index.js";
const [pdfPath, outputPath, sourceJson, dpiText, markersJson] =
process.argv.slice(2);
if (!pdfPath || !outputPath || !sourceJson) {
throw new Error("缺少 PDF 快照 worker 参数");
}
const source = JSON.parse(sourceJson);
const snapshot = await createPdfDocumentSnapshot(fs.readFileSync(pdfPath), {
source,
dpi: Number(dpiText || 144)
});
const markerDefinitions = JSON.parse(markersJson || "[]");
const markers = [];
for (const definition of markerDefinitions) {
const target = {
r: Number.parseInt(definition.color.slice(1, 3), 16),
g: Number.parseInt(definition.color.slice(3, 5), 16),
b: Number.parseInt(definition.color.slice(5, 7), 16)
};
let best;
for (const page of snapshot.pages) {
if (!page.raster) {
continue;
}
const image = await loadImage(Buffer.from(page.raster.png));
const canvas = createCanvas(page.raster.widthPx, page.raster.heightPx);
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
const pixels = context.getImageData(
0,
0,
page.raster.widthPx,
page.raster.heightPx
).data;
let count = 0;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = -1;
let maxY = -1;
for (let offset = 0; offset < pixels.length; offset += 4) {
if (
Math.abs(pixels[offset] - target.r) <= 12 &&
Math.abs(pixels[offset + 1] - target.g) <= 12 &&
Math.abs(pixels[offset + 2] - target.b) <= 12 &&
pixels[offset + 3] >= 200
) {
const pixelIndex = offset / 4;
const x = pixelIndex % page.raster.widthPx;
const y = Math.floor(pixelIndex / page.raster.widthPx);
count += 1;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (!best || count > best.count) {
best = {
pageNumber: page.pageNumber,
pageWidthPt: page.widthPt,
dpi: page.raster.dpi,
count,
minX,
minY,
maxX,
maxY
};
}
}
if (!best || best.count < 100) {
throw new Error(`${source.label} 未定位 ${definition.label}`);
}
const scale = 72 / best.dpi;
markers.push({
id: definition.id,
label: definition.label,
pageNumber: best.pageNumber,
pixelCount: best.count,
xPt: best.minX * scale,
yPt: best.minY * scale,
widthPt: (best.maxX - best.minX + 1) * scale,
heightPt: (best.maxY - best.minY + 1) * scale,
centerXPt: ((best.minX + best.maxX + 1) / 2) * scale,
pageWidthPt: best.pageWidthPt
});
}
fs.writeFileSync(outputPath, serialize({ snapshot, markers }));
@@ -0,0 +1,629 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import {
createPdfDocumentSnapshot,
createPdfVisualDiffReport,
createWordPdfAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import {
formatPageNumber,
resolvePageNumberAlignment
} from "../packages/preview-engine/dist/index.js";
const execFileAsync = promisify(execFile);
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const decoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-layout-visual-matrix"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const pageSizes = {
A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] },
Letter: { portrait: [612, 792], landscape: [792, 612] }
};
const strictIssueCodes = new Set([
"PAGE_SIZE_MISMATCH",
"PAGE_ORIENTATION_MISMATCH",
"CONTENT_MISMATCH",
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH",
"EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
"PARAGRAPH_LINE_BREAK_MISMATCH",
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
"COVER_LAYOUT_MISMATCH"
]);
const cases = [
{
id: "official-theme-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {}
},
{
id: "official-asymmetric-a4-portrait",
themeId: "gov-red-standard",
coverPageCount: 0,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "18mm",
right: "14mm",
bottom: "30mm",
left: "32mm"
}
}
}
},
{
id: "project-cover-custom-a4-portrait",
themeId: "formal-feasibility",
coverPageCount: 1,
paper: { format: "A4", orientation: "portrait" },
config: {
paper: {
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "20mm",
right: "18mm",
bottom: "25mm",
left: "30mm"
}
}
}
},
{
id: "tender-cover-custom-letter-landscape",
themeId: "tender-business-blue",
coverPageCount: 1,
paper: { format: "Letter", orientation: "landscape" },
config: {
paper: {
format: "Letter",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "16mm",
right: "18mm",
bottom: "20mm",
left: "24mm"
}
}
}
},
{
id: "formal-custom-a4-landscape",
themeId: "formal-regulation",
coverPageCount: 0,
paper: { format: "A4", orientation: "landscape" },
config: {
paper: {
format: "A4",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "15mm",
right: "22mm",
bottom: "18mm",
left: "25mm"
}
}
}
},
{
id: "general-compact-letter-portrait",
themeId: "typora-github",
coverPageCount: 0,
paper: { format: "Letter", orientation: "portrait" },
config: {
paper: {
format: "Letter",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "12.7mm",
right: "12.7mm",
bottom: "12.7mm",
left: "12.7mm"
}
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function paragraphStyleId(paragraph) {
const style = Array.from(paragraph.getElementsByTagName("*")).find(
(node) =>
node.localName === "pStyle" && node.namespaceURI === WORD_NAMESPACE
);
return style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
}
function paragraphRole(styleId) {
if (!styleId) {
return "body";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (
styleId === "Title" ||
/(?:Title|Masthead|ProjectName)$/u.test(styleId)
) {
return "title";
}
if (/Caption/u.test(styleId)) {
return "caption";
}
return "body";
}
function isInternalLayoutSpacerParagraph(descendants, text) {
if (text !== ".") {
return false;
}
const attributeValue = (node, name) =>
node.getAttribute(`w:${name}`) || node.getAttribute(name) || undefined;
return (
descendants.some(
(node) =>
node.localName === "sz" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "val") === "2"
) &&
descendants.some(
(node) =>
node.localName === "spacing" &&
node.namespaceURI === WORD_NAMESPACE &&
attributeValue(node, "lineRule") === "exact"
)
);
}
function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
decoder.decode(documentXml),
"application/xml"
);
let section = "cover";
let hasCoverSection = false;
const paragraphs = [];
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const hasSectionBreak = descendants.some(
(node) =>
node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
);
if (hasSectionBreak) {
hasCoverSection = true;
section = "body";
continue;
}
const text = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
continue;
}
const styleId = paragraphStyleId(paragraph);
paragraphs.push({
index: paragraphs.length,
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
section
});
}
if (!hasCoverSection) {
for (const paragraph of paragraphs) {
paragraph.section = "body";
}
}
return {
text: paragraphs.map((paragraph) => paragraph.text).join(""),
paragraphs
};
}
async function generateCase(caseDefinition) {
const caseDirectory = path.join(outputDirectory, caseDefinition.id);
fs.mkdirSync(caseDirectory, { recursive: true });
const { stdout } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 360_000,
maxBuffer: 24 * 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_R4_THEME_ID: caseDefinition.themeId,
MD_TO_PDF_R4_OUTPUT_DIR: caseDirectory,
MD_TO_PDF_R4_EXPORT_CONFIG_JSON: JSON.stringify(caseDefinition.config)
}
});
const result = JSON.parse(stdout).results?.[0];
assert(result, `视觉矩阵用例 ${caseDefinition.id} 没有生成产物`);
return { caseDirectory, result };
}
function expectedPageSize(caseDefinition) {
const dimensions =
pageSizes[caseDefinition.paper.format]?.[caseDefinition.paper.orientation];
assert(dimensions, `未知纸张配置:${JSON.stringify(caseDefinition.paper)}`);
return dimensions;
}
function pageGeometryFailures(snapshot, caseDefinition, label) {
const [expectedWidth, expectedHeight] = expectedPageSize(caseDefinition);
return snapshot.pages.flatMap((page) => {
const widthDelta = Math.abs(page.widthPt - expectedWidth);
const heightDelta = Math.abs(page.heightPt - expectedHeight);
return widthDelta <= 0.5 && heightDelta <= 0.5
? []
: [
`${label}${page.pageNumber} 页纸张尺寸为 ` +
`${page.widthPt.toFixed(3)}×${page.heightPt.toFixed(3)} pt` +
`期望 ${expectedWidth.toFixed(3)}×${expectedHeight.toFixed(3)} pt`
];
});
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
`物理页数 ${pageCount} 必须大于封面页数 ${coverPageCount}`
);
const logicalPageCount = pageCount - coverPageCount;
const headerSlots = ["left", "center", "right"].flatMap((alignment) => {
const slot = config.header[alignment];
return config.header.enabled && slot.enabled && slot.content
? [{ alignment, text: slot.content }]
: [];
});
return Array.from({ length: pageCount }, (_, index) => {
const physicalPageNumber = index + 1;
if (physicalPageNumber <= coverPageCount) {
return {
physicalPageNumber,
kind: "cover",
logicalPageCount,
headerVisible: false,
headerSlots,
footerVisible: false
};
}
const bodyPageIndex = index - coverPageCount;
const isBodyFirst = bodyPageIndex === 0;
const headerVisible =
config.header.enabled &&
(!isBodyFirst || config.header.showOnFirstPage);
const footerVisible =
config.footer.enabled &&
(!isBodyFirst || config.footer.showOnFirstPage);
return {
physicalPageNumber,
kind: isBodyFirst ? "body-first" : "body-rest",
logicalPageNumber: config.footer.startFrom + bodyPageIndex,
logicalPageCount,
headerVisible,
headerSlots,
footerVisible,
...(footerVisible
? {
footerAlignment: resolvePageNumberAlignment(
config.footer,
bodyPageIndex
),
pageNumberText: formatPageNumber(
config.footer,
bodyPageIndex,
logicalPageCount
)
}
: {})
};
});
}
function reportGateFailures(report, label) {
const failures = report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
if (report.basic.baselineEditableCoverage !== 1) {
failures.push(
`${label}: Chromium 可编辑正文有序覆盖率不是 100%`
);
}
if (report.basic.candidateEditableExact !== true) {
failures.push(`${label}: Office 可编辑正文未精确匹配`);
}
if (report.basic.bodyFlow?.detected && !report.basic.bodyFlow.legal) {
failures.push(`${label}: 分页差异未满足合法正文流条件`);
}
return failures;
}
function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues
) ?? [];
return {
status: report.status,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
strictIssueCodes: [
...new Set(
report.issues
.filter((issue) => strictIssueCodes.has(issue.code))
.map((issue) => issue.code)
)
],
editableExact: report.basic.candidateEditableExact,
paragraphIssueCount: paragraphIssues.length,
bodyFlow: report.basic.bodyFlow
};
}
function writeSnapshotPages(directory, prefix, snapshot) {
for (const page of snapshot.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(directory, `${prefix}-${page.pageNumber}.png`),
page.raster.png
);
}
}
}
function writeReportArtifacts(directory, prefix, report) {
fs.writeFileSync(
path.join(directory, `${prefix}-report.json`),
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(
path.join(directory, `${prefix}-report.html`),
renderPdfVisualDiffHtml(report),
"utf8"
);
for (const page of report.pages) {
const pageNumber =
page.pair.baselinePageNumber ?? page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(directory, `${prefix}-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(directory, `${prefix}-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`视觉矩阵用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
const wpsCapability = await wpsAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
const gateFailures = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(`[layout visual matrix] ${caseDefinition.id}\n`);
const { caseDirectory, result } = await generateCase(caseDefinition);
const chromiumPdfPath = path.resolve(
repositoryDirectory,
result.pdf.outputFile
);
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const editable = extractEditableContract(docxPath);
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const chromium = await createPdfDocumentSnapshot(
fs.readFileSync(chromiumPdfPath),
{
source: {
kind: "chromium",
label: `${caseDefinition.id} Chromium`,
fileName: path.basename(chromiumPdfPath)
},
dpi: 144
}
);
const word = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: {
kind: "word",
label: `${caseDefinition.id} Microsoft Word`,
fileName: path.basename(wordPdfPath)
},
dpi: 144
});
const wps = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: {
kind: "wps",
label: `${caseDefinition.id} WPS Writer`,
fileName: path.basename(wpsPdfPath)
},
dpi: 144
});
const chromiumPageSemantics = buildPageSemantics(
chromium.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
word.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsPageSemantics = buildPageSemantics(
wps.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const reportOptions = {
thresholds: { strictPageCount: false },
expectedEditableText: editable.text,
expectedEditableParagraphs: editable.paragraphs
};
const wordReport = await createPdfVisualDiffReport(
chromium,
word,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wordPageSemantics
}
);
const wpsReport = await createPdfVisualDiffReport(
chromium,
wps,
{
...reportOptions,
baselinePageSemantics: chromiumPageSemantics,
candidatePageSemantics: wpsPageSemantics
}
);
const officeReport = await createPdfVisualDiffReport(word, wps, {
...reportOptions,
expectedEditableText: editable.text,
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
fs.mkdirSync(rasterDirectory, { recursive: true });
writeSnapshotPages(rasterDirectory, "chromium", chromium);
writeSnapshotPages(rasterDirectory, "word", word);
writeSnapshotPages(rasterDirectory, "wps", wps);
writeReportArtifacts(rasterDirectory, "word", wordReport);
writeReportArtifacts(rasterDirectory, "wps", wpsReport);
writeReportArtifacts(rasterDirectory, "office", officeReport);
const currentFailures = [
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
];
gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
paper: caseDefinition.paper,
exportConfig: result.exportConfig,
coverPageCount: caseDefinition.coverPageCount,
pages: {
chromium: chromium.pageCount,
word: word.pageCount,
wps: wps.pageCount
},
editableParagraphCount: editable.paragraphs.length,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
office: reportSummary(officeReport)
},
gateFailures: currentFailures,
files: {
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
docx: path.relative(repositoryDirectory, docxPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
rasterDirectory: path.relative(repositoryDirectory, rasterDirectory)
}
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
caseCount: summaries.length,
gatePassed: gateFailures.length === 0,
gateFailures,
cases: summaries
};
const summaryPath = path.join(outputDirectory, "summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
assert(
gateFailures.length === 0,
`布局视觉矩阵存在 ${gateFailures.length} 个严格门禁问题,详见 ${path.relative(
repositoryDirectory,
summaryPath
)}`
);
+761
View File
@@ -0,0 +1,761 @@
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import net from "node:net";
import path from "node:path";
import { deserialize } from "node:v8";
import { createCanvas } from "@napi-rs/canvas";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import { defaultExportConfig } from "../packages/core/dist/index.js";
import {
createPdfVisualDiffReport,
createWordDocxRoundTripAdapter,
createWordPdfAdapter,
createWpsDocxRoundTripAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import { buildApp } from "../apps/server/dist/app.js";
import { createDocxMediaEngine } from "../apps/server/dist/docx-media-engine.js";
import { createPdfGenerator } from "../apps/server/dist/pdf-engine.js";
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const fixturePath = path.join(
repositoryDirectory,
"packages",
"docx-engine",
"fixtures",
"docx-media-visual.md"
);
const webDirectory = path.join(
repositoryDirectory,
"apps",
"web",
"dist"
);
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-media-gate"
);
const snapshotWorkerPath = path.join(
repositoryDirectory,
"scripts",
"create-pdf-snapshot-artifact.mjs"
);
const rasterDirectory = path.join(outputDirectory, "raster-pages");
const decoder = new TextDecoder();
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
const markerDefinitions = [
{ id: "normal", label: "普通图片", color: "#7DD3FC" },
{ id: "wide", label: "宽图", color: "#FDBA74" },
{
id: "tall",
label: "高图",
color: "#86EFAC",
paginationScaleFlexible: true
},
{
id: "mermaid",
label: "Mermaid",
color: "#C4B5FD",
markerRatioTolerance: 0.08
},
{ id: "echarts", label: "ECharts", color: "#F9A8D4" }
];
const captionDefinitions = [
{ id: "normal", text: "普通图片:蓝色4:3" },
{ id: "wide", text: "宽图:橙色8:3" },
{ id: "tall", text: "高图:绿色3:8" },
{ id: "echarts", text: "ECharts紫色柱状图" }
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function createIsolatedPdfSnapshot(pdfPath, source) {
const snapshotPath = path.join(
outputDirectory,
`.snapshot-${randomUUID()}.bin`
);
try {
execFileSync(
process.execPath,
[
snapshotWorkerPath,
pdfPath,
snapshotPath,
JSON.stringify(source),
"144",
JSON.stringify(markerDefinitions)
],
{ cwd: repositoryDirectory, stdio: "inherit", timeout: 180_000 }
);
return deserialize(fs.readFileSync(snapshotPath));
} finally {
fs.rmSync(snapshotPath, { force: true });
}
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配媒体验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) =>
reply
.type("text/html; charset=utf-8")
.send(fs.readFileSync(resolveWebFile("preview-frame.html")))
);
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
assert(fs.statSync(filePath).isFile(), `Web 资源无效:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function createMarkerPng(width, height, background, accent, label) {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = background;
context.fillRect(0, 0, width, height);
context.strokeStyle = accent;
context.lineWidth = Math.max(8, Math.round(Math.min(width, height) / 35));
context.strokeRect(
context.lineWidth / 2,
context.lineWidth / 2,
width - context.lineWidth,
height - context.lineWidth
);
context.globalAlpha = 0.22;
context.lineWidth = 2;
for (let index = 1; index < 8; index += 1) {
context.beginPath();
context.moveTo((width * index) / 8, 0);
context.lineTo((width * index) / 8, height);
context.stroke();
}
for (let index = 1; index < 6; index += 1) {
context.beginPath();
context.moveTo(0, (height * index) / 6);
context.lineTo(width, (height * index) / 6);
context.stroke();
}
context.globalAlpha = 1;
context.fillStyle = accent;
context.textAlign = "center";
context.textBaseline = "middle";
context.font = `bold ${Math.max(28, Math.round(Math.min(width, height) / 7))}px sans-serif`;
context.fillText(label, width / 2, height / 2);
return canvas.toBuffer("image/png");
}
function createResources() {
return [
{
path: "media/normal.png",
contentType: "image/png",
data: createMarkerPng(
640,
480,
"#7DD3FC",
"#1D4ED8",
"NORMAL 4:3"
).toString("base64")
},
{
path: "media/wide.png",
contentType: "image/png",
data: createMarkerPng(
1600,
600,
"#FDBA74",
"#C2410C",
"WIDE 8:3"
).toString("base64")
},
{
path: "media/tall.png",
contentType: "image/png",
data: createMarkerPng(
600,
1600,
"#86EFAC",
"#15803D",
"TALL 3:8"
).toString("base64")
}
];
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效`
);
assert(
Number(response.headers.get("x-echarts-error-count")) === 0 &&
Number(response.headers.get("x-mermaid-error-count")) === 0,
`${route} 图表渲染存在错误`
);
return content;
}
function extractEditableContract(docx) {
const entries = unzipSync(docx);
const documentXml = entries["word/document.xml"];
assert(documentXml, "DOCX 缺少 document.xml");
const document = new DOMParser().parseFromString(
decoder.decode(documentXml),
"application/xml"
);
const text = Array.from(document.getElementsByTagName("*")).flatMap(
(node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
const paragraphs = Array.from(document.getElementsByTagName("*"))
.filter(
(node) =>
node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)
.flatMap((paragraph) => {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const paragraphText = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!paragraphText) {
return [];
}
const style = descendants.find(
(node) =>
node.localName === "pStyle" &&
node.namespaceURI === WORD_NAMESPACE
);
const styleId =
style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
return [
{
index: 0,
text: paragraphText,
...(styleId ? { styleId } : {}),
role:
styleId === "Title" || /Heading/u.test(styleId ?? "")
? "heading"
: /Caption/u.test(styleId ?? "")
? "caption"
: "body",
section: "body"
}
];
})
.map((paragraph, index) => ({ ...paragraph, index }));
return { text, paragraphs };
}
function readDrawingExtents(documentXml) {
return [...documentXml.matchAll(/<wp:extent cx="(\d+)" cy="(\d+)"/gu)].map(
(match) => ({ cx: Number(match[1]), cy: Number(match[2]) })
);
}
function inspectRoundTripDocx(docx, sourceDocx, label) {
const entries = unzipSync(docx);
const sourceEntries = unzipSync(sourceDocx);
const documentXml = decoder.decode(entries["word/document.xml"]);
const sourceDocumentXml = decoder.decode(
sourceEntries["word/document.xml"]
);
const relationshipsXml = decoder.decode(
entries["word/_rels/document.xml.rels"]
);
const drawingCount = documentXml.match(/<w:drawing(?:\s|>)/gu)?.length ?? 0;
const inlineCount = documentXml.match(/<wp:inline(?:\s|>)/gu)?.length ?? 0;
const anchorCount = documentXml.match(/<wp:anchor(?:\s|>)/gu)?.length ?? 0;
const pngRelationships = [
...relationshipsXml.matchAll(
/Type="[^"]*\/image"[^>]*Target="([^"]+)"/gu
)
].filter((match) => match[1]?.toLowerCase().endsWith(".png"));
assert(drawingCount === 5, `${label} Drawing 数量为 ${drawingCount}`);
assert(inlineCount === 5 && anchorCount === 0, `${label} 出现浮动图片`);
assert(pngRelationships.length === 5, `${label} 未保留五个 PNG 关系`);
assert(!documentXml.includes("mdtp-media:"), `${label} 残留内部标记`);
assert(!documentXml.includes("<a:srcRect"), `${label} 出现图片裁切`);
const sourceExtents = readDrawingExtents(sourceDocumentXml);
const savedExtents = readDrawingExtents(documentXml);
assert(
sourceExtents.length === 5 && savedExtents.length === 5,
`${label} 图片尺寸记录数量无效`
);
let maxUntargetedDriftPt = 0;
for (let index = 0; index < sourceExtents.length; index += 1) {
const expectedScale = index === 0 ? 0.9 : 1;
const expectedCx = sourceExtents[index].cx * expectedScale;
const expectedCy = sourceExtents[index].cy * expectedScale;
const driftPt = Math.max(
Math.abs(savedExtents[index].cx - expectedCx) / 12700,
Math.abs(savedExtents[index].cy - expectedCy) / 12700
);
assert(
driftPt <= 1,
`${label} 图片 ${index + 1} OOXML 尺寸漂移 ${driftPt.toFixed(3)} pt`
);
if (index > 0) {
maxUntargetedDriftPt = Math.max(maxUntargetedDriftPt, driftPt);
}
}
return {
drawingCount,
inlineCount,
anchorCount,
pngRelationshipCount: 5,
maxUntargetedDriftPt
};
}
function assertRoundTrip(result, label) {
assert(result.before.length === 5, `${label} 打开时图片数量无效`);
assert(result.saved.length === 5, `${label} 保存后图片数量无效`);
const before = result.before[0];
const saved = result.saved[0];
assert(before && saved, `${label} 缺少第一张图片尺寸`);
const widthScale = saved.widthPt / before.widthPt;
const heightScale = saved.heightPt / before.heightPt;
assert(
Math.abs(widthScale - 0.9) <= 0.02 &&
Math.abs(heightScale - 0.9) <= 0.02,
`${label} 未按比例保存缩放:${widthScale}/${heightScale}`
);
return { widthScale, heightScale };
}
function compareMarkers(baseline, candidate, label, options = {}) {
return baseline.map((expected) => {
const actual = candidate.find((entry) => entry.id === expected.id);
const definition = markerDefinitions.find(
(entry) => entry.id === expected.id
);
assert(actual, `${label} 缺少 ${expected.label}`);
const widthDelta = Math.abs(actual.widthPt / expected.widthPt - 1);
const heightDelta = Math.abs(actual.heightPt / expected.heightPt - 1);
const expectedRatio = expected.widthPt / expected.heightPt;
const actualRatio = actual.widthPt / actual.heightPt;
const ratioDelta = Math.abs(actualRatio / expectedRatio - 1);
const expectedCenterOffset =
expected.centerXPt - expected.pageWidthPt / 2;
const actualCenterOffset = actual.centerXPt - actual.pageWidthPt / 2;
const centerDeltaPt = Math.abs(actualCenterOffset - expectedCenterOffset);
const physicalSizeRequired =
options.requirePhysicalSize === true ||
!definition?.paginationScaleFlexible;
const sizeTolerance = options.sizeTolerance ?? 0.08;
if (physicalSizeRequired) {
assert(
widthDelta <= sizeTolerance,
`${label} ${expected.label} 宽度偏差过大:` +
`${expected.widthPt}/${actual.widthPt} pt, ` +
`${(widthDelta * 100).toFixed(2)}%`
);
assert(
heightDelta <= sizeTolerance,
`${label} ${expected.label} 高度偏差过大:` +
`${expected.heightPt}/${actual.heightPt} pt, ` +
`${(heightDelta * 100).toFixed(2)}%`
);
}
const ratioTolerance = definition?.markerRatioTolerance ?? 0.04;
assert(
ratioDelta <= ratioTolerance,
`${label} ${expected.label} 比例偏差过大:` +
`${expectedRatio.toFixed(4)}/${actualRatio.toFixed(4)}, ` +
`${(ratioDelta * 100).toFixed(2)}%`
);
assert(centerDeltaPt <= 8, `${label} ${expected.label} 水平对齐偏差过大`);
return {
id: expected.id,
widthDelta,
heightDelta,
ratioDelta,
centerDeltaPt,
physicalSizeRequired,
baselinePage: expected.pageNumber,
candidatePage: actual.pageNumber
};
});
}
function inspectCaptionCenters(snapshot, label) {
return captionDefinitions.map((definition) => {
let observation;
for (const page of snapshot.pages) {
const line = page.lines.find(
(entry) => entry.normalizedText.replace(/\s+/gu, "") === definition.text
);
if (line) {
const centerXPt = line.bounds.x + line.bounds.width / 2;
observation = {
id: definition.id,
text: definition.text,
pageNumber: page.pageNumber,
centerXPt,
pageWidthPt: page.widthPt,
centerDeltaPt: Math.abs(centerXPt - page.widthPt / 2)
};
break;
}
}
assert(observation, `${label} 缺少题注:${definition.text}`);
assert(
observation.centerDeltaPt <= 8,
`${label} 题注未居中:${definition.text}` +
`${observation.centerDeltaPt.toFixed(2)} pt`
);
return observation;
});
}
function writeReportArtifacts(prefix, report, snapshot) {
fs.writeFileSync(
path.join(outputDirectory, `${prefix}-report.json`),
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(
path.join(outputDirectory, `${prefix}-report.html`),
renderPdfVisualDiffHtml(report),
"utf8"
);
for (const page of snapshot.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `${prefix}-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of report.pages) {
const pageNumber =
page.pair.baselinePageNumber ?? page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `${prefix}-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `${prefix}-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
}
fs.mkdirSync(outputDirectory, { recursive: true });
fs.mkdirSync(rasterDirectory, { recursive: true });
const markdown = fs.readFileSync(fixturePath, "utf8");
const exportConfig = {
...defaultExportConfig,
name: "DOCX 媒体视觉专项验收",
themeId: "typora-github",
pageDecorationsMode: "custom",
header: { ...defaultExportConfig.header, enabled: false },
footer: { ...defaultExportConfig.footer, enabled: false },
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "20mm",
right: "20mm",
bottom: "20mm",
left: "20mm"
}
}
};
const payload = {
markdown,
fileName: "docx-media-visual.md",
language: "zh-CN",
resources: createResources(),
exportConfig
};
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter
});
registerWebRuntime(app);
let chromiumPdf;
let docx;
try {
await app.listen({ port, host: "127.0.0.1" });
chromiumPdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
} finally {
await app.close();
}
const chromiumPdfPath = path.join(outputDirectory, "chromium.pdf");
const docxPath = path.join(outputDirectory, "source.docx");
fs.writeFileSync(chromiumPdfPath, chromiumPdf);
fs.writeFileSync(docxPath, docx);
const editableContract = extractEditableContract(docx);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordRoundTripAdapter = createWordDocxRoundTripAdapter({
timeoutMs: 240_000
});
const wpsRoundTripAdapter = createWpsDocxRoundTripAdapter({
timeoutMs: 240_000
});
const capabilities = {
word: await wordAdapter.probe(),
wps: await wpsAdapter.probe(),
wordRoundTrip: await wordRoundTripAdapter.probe(),
wpsRoundTrip: await wpsRoundTripAdapter.probe()
};
for (const [name, capability] of Object.entries(capabilities)) {
assert(capability.available, `${name} 不可用:${capability.detail}`);
}
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(outputDirectory, "word.pdf");
const wpsPdfPath = path.join(outputDirectory, "wps.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
assert(
Buffer.from(fs.readFileSync(wordPdfPath)).equals(
Buffer.from(wordGeneration.pdf)
),
"Word PDF 落盘字节与生成结果不一致"
);
assert(
Buffer.from(fs.readFileSync(wpsPdfPath)).equals(
Buffer.from(wpsGeneration.pdf)
),
"WPS PDF 落盘字节与生成结果不一致"
);
const wordRoundTrip = await wordRoundTripAdapter.generate({ docxPath });
const wpsRoundTrip = await wpsRoundTripAdapter.generate({ docxPath });
const wordRoundTripPath = path.join(outputDirectory, "word-round-trip.docx");
const wpsRoundTripPath = path.join(outputDirectory, "wps-round-trip.docx");
fs.writeFileSync(wordRoundTripPath, wordRoundTrip.docx);
fs.writeFileSync(wpsRoundTripPath, wpsRoundTrip.docx);
const roundTripScale = {
word: assertRoundTrip(wordRoundTrip, "Microsoft Word"),
wps: assertRoundTrip(wpsRoundTrip, "WPS Writer")
};
const roundTripStructure = {
word: inspectRoundTripDocx(wordRoundTrip.docx, docx, "Microsoft Word"),
wps: inspectRoundTripDocx(wpsRoundTrip.docx, docx, "WPS Writer")
};
const wordRoundTripPdf = await wordAdapter.generate({
docxPath: wordRoundTripPath
});
const wpsRoundTripPdf = await wpsAdapter.generate({
docxPath: wpsRoundTripPath
});
fs.writeFileSync(
path.join(outputDirectory, "word-round-trip.pdf"),
wordRoundTripPdf.pdf
);
fs.writeFileSync(
path.join(outputDirectory, "wps-round-trip.pdf"),
wpsRoundTripPdf.pdf
);
const baselineArtifact = createIsolatedPdfSnapshot(chromiumPdfPath, {
kind: "chromium",
label: "Chromium 媒体基线"
});
const wordArtifact = createIsolatedPdfSnapshot(wordPdfPath, {
kind: "word",
label: "Microsoft Word"
});
const wpsArtifact = createIsolatedPdfSnapshot(wpsPdfPath, {
kind: "wps",
label: "WPS Writer"
});
const baseline = baselineArtifact.snapshot;
const wordSnapshot = wordArtifact.snapshot;
const wpsSnapshot = wpsArtifact.snapshot;
const chromiumMarkers = baselineArtifact.markers;
const wordMarkers = wordArtifact.markers;
const wpsMarkers = wpsArtifact.markers;
const captionCenters = {
chromium: inspectCaptionCenters(baseline, "Chromium"),
word: inspectCaptionCenters(wordSnapshot, "Microsoft Word"),
wps: inspectCaptionCenters(wpsSnapshot, "WPS Writer")
};
const reportOptions = {
expectedEditableText: editableContract.text,
expectedEditableParagraphs: editableContract.paragraphs
};
const wordReport = await createPdfVisualDiffReport(
baseline,
wordSnapshot,
reportOptions
);
const wpsReport = await createPdfVisualDiffReport(
baseline,
wpsSnapshot,
reportOptions
);
assert(
wordReport.basic.candidateEditableExact,
"Word 导出 PDF 的可编辑文本不完整"
);
assert(
wpsReport.basic.candidateEditableExact,
"WPS 导出 PDF 的可编辑文本不完整"
);
const markerComparisons = {
word: compareMarkers(chromiumMarkers, wordMarkers, "Microsoft Word"),
wps: compareMarkers(chromiumMarkers, wpsMarkers, "WPS Writer"),
officeParity: compareMarkers(wordMarkers, wpsMarkers, "Word/WPS", {
requirePhysicalSize: true,
sizeTolerance: 0.04
})
};
writeReportArtifacts("chromium", wordReport, baseline);
writeReportArtifacts("word", wordReport, wordSnapshot);
writeReportArtifacts("wps", wpsReport, wpsSnapshot);
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
fixture: path.relative(repositoryDirectory, fixturePath),
exportConfig,
capabilities,
pages: {
chromium: baseline.pageCount,
word: wordSnapshot.pageCount,
wps: wpsSnapshot.pageCount
},
reports: {
word: {
status: wordReport.status,
issueCodes: [...new Set(wordReport.issues.map((issue) => issue.code))],
editableExact: wordReport.basic.candidateEditableExact
},
wps: {
status: wpsReport.status,
issueCodes: [...new Set(wpsReport.issues.map((issue) => issue.code))],
editableExact: wpsReport.basic.candidateEditableExact
}
},
markers: {
chromium: chromiumMarkers,
word: wordMarkers,
wps: wpsMarkers,
comparisons: markerComparisons
},
captionCenters,
roundTrip: {
scale: roundTripScale,
structure: roundTripStructure,
word: {
bytes: wordRoundTrip.docx.byteLength,
before: wordRoundTrip.before,
saved: wordRoundTrip.saved
},
wps: {
bytes: wpsRoundTrip.docx.byteLength,
before: wpsRoundTrip.before,
saved: wpsRoundTrip.saved
}
}
};
fs.writeFileSync(
path.join(outputDirectory, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
"utf8"
);
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
@@ -0,0 +1,519 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import {
createPdfDocumentSnapshot,
createPdfVisualDiffReport,
createWordPdfAdapter,
createWpsPdfAdapter,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
import {
formatPageNumber,
resolvePageNumberAlignment
} from "../packages/preview-engine/dist/index.js";
const execFileAsync = promisify(execFile);
const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const textDecoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-page-decoration-gate"
);
const matrixScript = path.join(
repositoryDirectory,
"apps",
"server",
"scripts",
"verify-docx-r4-matrix.mjs"
);
const commonHeader = {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
left: { enabled: true, content: "页眉左栏" },
center: { enabled: true, content: "页眉中栏" },
right: { enabled: true, content: "页眉右栏" }
};
const cases = [
{
id: "no-cover",
themeId: "typora-github",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: false },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "center",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
},
{
id: "official",
themeId: "gov-red-standard",
coverPageCount: 0,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "7mm",
showDivider: false,
fontSize: "3mm",
fontFamily: '"FangSong", "SimSun", serif',
color: "#000000",
alignment: "outer",
format: "official-page",
startFrom: 1,
showOnFirstPage: false
}
}
},
{
id: "project-cover",
themeId: "formal-feasibility",
coverPageCount: 1,
config: {
pageDecorationsMode: "custom",
header: { ...commonHeader, showOnFirstPage: true },
footer: {
enabled: true,
height: "8mm",
showDivider: true,
fontSize: "3mm",
fontFamily: '"Microsoft YaHei", sans-serif',
color: "#44546a",
alignment: "outer",
format: "page-total",
startFrom: 1,
showOnFirstPage: true
}
}
}
];
const selectedCaseId =
process.env.MD_TO_PDF_PAGE_DECORATION_CASE?.trim() || undefined;
const selectedCases = selectedCaseId
? cases.filter((caseDefinition) => caseDefinition.id === selectedCaseId)
: cases;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function extractDocxEditableText(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
return Array.from(document.getElementsByTagName("*")).flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
}
function paragraphStyleId(paragraph) {
const style = Array.from(paragraph.getElementsByTagName("*")).find(
(node) => node.localName === "pStyle" && node.namespaceURI === WORD_NAMESPACE
);
return style?.getAttribute("w:val") || style?.getAttribute("val") || undefined;
}
function paragraphRole(styleId) {
if (!styleId) {
return "body";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (
styleId === "Title" ||
/(?:Title|Masthead|ProjectName)$/u.test(styleId)
) {
return "title";
}
if (/Caption/u.test(styleId)) {
return "caption";
}
return "body";
}
function extractDocxEditableParagraphs(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
assert(documentXml, `DOCX 缺少 word/document.xml${docxPath}`);
const document = new DOMParser().parseFromString(
textDecoder.decode(documentXml),
"application/xml"
);
let section = "cover";
let hasCoverSection = false;
const paragraphs = [];
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const hasSectionBreak = descendants.some(
(node) => node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
);
if (hasSectionBreak) {
hasCoverSection = true;
section = "body";
continue;
}
const text = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE || node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
if (!text) {
continue;
}
const styleId = paragraphStyleId(paragraph);
paragraphs.push({
index: paragraphs.length,
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
section
});
}
if (!hasCoverSection) {
for (const paragraph of paragraphs) {
paragraph.section = "body";
}
}
return paragraphs;
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
`物理页数 ${pageCount} 必须大于封面页数 ${coverPageCount}`
);
const logicalPageCount = pageCount - coverPageCount;
const headerSlots = ["left", "center", "right"].flatMap((alignment) => {
const slot = config.header[alignment];
return config.header.enabled && slot.enabled && slot.content
? [{ alignment, text: slot.content }]
: [];
});
return Array.from({ length: pageCount }, (_, index) => {
const physicalPageNumber = index + 1;
if (physicalPageNumber <= coverPageCount) {
return {
physicalPageNumber,
kind: "cover",
logicalPageCount,
headerVisible: false,
headerSlots,
footerVisible: false
};
}
const bodyPageIndex = index - coverPageCount;
const isBodyFirst = bodyPageIndex === 0;
const headerVisible =
config.header.enabled &&
(!isBodyFirst || config.header.showOnFirstPage);
const footerVisible =
config.footer.enabled &&
(!isBodyFirst || config.footer.showOnFirstPage);
return {
physicalPageNumber,
kind: isBodyFirst ? "body-first" : "body-rest",
logicalPageNumber: config.footer.startFrom + bodyPageIndex,
logicalPageCount,
headerVisible,
headerSlots,
footerVisible,
...(footerVisible
? {
footerAlignment: resolvePageNumberAlignment(
config.footer,
bodyPageIndex
),
pageNumberText: formatPageNumber(
config.footer,
bodyPageIndex,
logicalPageCount
)
}
: {})
};
});
}
async function generateCase(caseDefinition) {
const caseDirectory = path.join(outputDirectory, caseDefinition.id);
fs.mkdirSync(caseDirectory, { recursive: true });
const { stdout } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 300_000,
maxBuffer: 16 * 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_R4_THEME_ID: caseDefinition.themeId,
MD_TO_PDF_R4_OUTPUT_DIR: caseDirectory,
MD_TO_PDF_R4_EXPORT_CONFIG_JSON: JSON.stringify(caseDefinition.config)
}
});
const matrix = JSON.parse(stdout);
const result = matrix.results?.[0];
assert(result, `代表用例 ${caseDefinition.id} 没有生成矩阵结果`);
return { caseDirectory, result };
}
fs.mkdirSync(outputDirectory, { recursive: true });
assert(
selectedCases.length > 0,
`页装饰视觉门禁用例不存在:${selectedCaseId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
assert(wordCapability.available, `Microsoft Word 不可用:${wordCapability.detail}`);
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wpsCapability = await wpsAdapter.probe();
assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`);
const summaries = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(
`[page decoration gate] generating ${caseDefinition.id}\n`
);
const { caseDirectory, result } = await generateCase(caseDefinition);
const chromiumPdfPath = path.resolve(
repositoryDirectory,
result.pdf.outputFile
);
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const expectedEditableText = extractDocxEditableText(docxPath);
const expectedEditableParagraphs = extractDocxEditableParagraphs(docxPath);
const chromiumPdf = Uint8Array.from(fs.readFileSync(chromiumPdfPath));
const wordGeneration = await wordAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
fs.writeFileSync(wordPdfPath, wordGeneration.pdf);
const baseline = await createPdfDocumentSnapshot(chromiumPdf, {
source: { kind: "chromium", label: `${caseDefinition.id} Chromium` },
dpi: 144
});
const candidate = await createPdfDocumentSnapshot(wordGeneration.pdf, {
source: { kind: "word", label: `${caseDefinition.id} Microsoft Word` },
dpi: 144
});
const baselinePageSemantics = buildPageSemantics(
baseline.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
candidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const report = await createPdfVisualDiffReport(baseline, candidate, {
baselinePageSemantics,
candidatePageSemantics: wordPageSemantics,
expectedEditableText,
expectedEditableParagraphs
});
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wpsPdfPath = path.join(caseDirectory, "wps.pdf");
fs.writeFileSync(wpsPdfPath, wpsGeneration.pdf);
const wpsCandidate = await createPdfDocumentSnapshot(wpsGeneration.pdf, {
source: { kind: "wps", label: `${caseDefinition.id} WPS Writer` },
dpi: 144
});
const wpsPageSemantics = buildPageSemantics(
wpsCandidate.pageCount,
caseDefinition.coverPageCount,
result.exportConfig
);
const wpsReport = await createPdfVisualDiffReport(
baseline,
wpsCandidate,
{
baselinePageSemantics,
candidatePageSemantics: wpsPageSemantics,
expectedEditableText,
expectedEditableParagraphs
}
);
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.mkdirSync(rasterDirectory, { recursive: true });
for (const page of baseline.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `chromium-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of candidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `word-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of wpsCandidate.pages) {
if (page.raster) {
fs.writeFileSync(
path.join(rasterDirectory, `wps-${page.pageNumber}.png`),
page.raster.png
);
}
}
for (const page of report.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
for (const page of wpsReport.pages) {
const pageNumber = page.pair.baselinePageNumber ??
page.pair.candidatePageNumber;
if (!pageNumber || !page.artifacts) {
continue;
}
fs.writeFileSync(
path.join(rasterDirectory, `wps-overlay-${pageNumber}.png`),
page.artifacts.overlayPng
);
fs.writeFileSync(
path.join(rasterDirectory, `wps-heatmap-${pageNumber}.png`),
page.artifacts.heatmapPng
);
}
const reportJsonPath = path.join(caseDirectory, "report.json");
const reportHtmlPath = path.join(caseDirectory, "report.html");
const wpsReportJsonPath = path.join(caseDirectory, "wps-report.json");
const wpsReportHtmlPath = path.join(caseDirectory, "wps-report.html");
fs.writeFileSync(
reportJsonPath,
`${serializePdfVisualDiffJson(report)}\n`,
"utf8"
);
fs.writeFileSync(reportHtmlPath, renderPdfVisualDiffHtml(report), "utf8");
fs.writeFileSync(
wpsReportJsonPath,
`${serializePdfVisualDiffJson(wpsReport)}\n`,
"utf8"
);
fs.writeFileSync(
wpsReportHtmlPath,
renderPdfVisualDiffHtml(wpsReport),
"utf8"
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
status: report.status,
chromiumPages: baseline.pageCount,
wordPages: candidate.pageCount,
editableTextLength: expectedEditableText.length,
editableParagraphCount: expectedEditableParagraphs.length,
paragraphLayoutIssueCount: report.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
bodyFlow: report.basic.bodyFlow,
baselineEditableCoverage: report.basic.baselineEditableCoverage,
wordEditableSimilarity: report.basic.candidateEditableSimilarity,
wordEditableExact: report.basic.candidateEditableExact,
semanticIssueCount: report.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
wpsStatus: wpsReport.status,
wpsPages: wpsCandidate.pageCount,
wpsEditableSimilarity: wpsReport.basic.candidateEditableSimilarity,
wpsEditableExact: wpsReport.basic.candidateEditableExact,
wpsParagraphLayoutIssueCount: wpsReport.basic.paragraphLayouts
?.flatMap((paragraph) => paragraph.issues).length ?? 0,
wpsBodyFlow: wpsReport.basic.bodyFlow,
wpsSemanticIssueCount: wpsReport.issues.filter((issue) =>
[
"PAGE_NUMBER_VISIBILITY_MISMATCH",
"PAGE_NUMBER_TEXT_MISMATCH",
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
"HEADER_VISIBILITY_MISMATCH",
"HEADER_TEXT_MISSING",
"HEADER_VERTICAL_POSITION_MISMATCH"
].includes(issue.code)
).length,
wpsIssueCodes: [
...new Set(wpsReport.issues.map((issue) => issue.code))
],
reportHtml: path.relative(repositoryDirectory, reportHtmlPath),
reportJson: path.relative(repositoryDirectory, reportJsonPath),
wpsReportHtml: path.relative(repositoryDirectory, wpsReportHtmlPath),
wpsReportJson: path.relative(repositoryDirectory, wpsReportJsonPath),
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
wordPdf: path.relative(repositoryDirectory, wordPdfPath),
wpsPdf: path.relative(repositoryDirectory, wpsPdfPath),
docx: path.relative(repositoryDirectory, docxPath)
});
}
const summary = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
selectedCaseId,
word: wordCapability,
wps: wpsCapability,
cases: summaries
};
const summaryPath = path.join(outputDirectory, "summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
+2 -2
View File
@@ -10,7 +10,7 @@
#write {
color: var(--feasibility-ink);
font-family:
"Noto Serif CJK SC", "Source Han Serif SC",
"FandolSong", "Noto Serif CJK SC", "Source Han Serif SC",
"Microsoft YaHei", "SimSun", serif;
font-size: 17px;
line-height: 1.8;
@@ -70,7 +70,7 @@
#write h3,
#write h4 {
font-family:
"Noto Sans CJK SC", "Source Han Sans SC",
"FandolHei", "Noto Sans CJK SC", "Source Han Sans SC",
"Microsoft YaHei", sans-serif;
font-weight: 700;
}
+25
View File
@@ -21,6 +21,31 @@
"left": "30mm"
}
},
"docxFonts": {
"faces": [
{
"family": "FandolSong",
"aliases": ["Noto Serif CJK SC", "Source Han Serif SC", "SimSun"],
"source": "theme-shared:official-fonts/FandolSong-Regular.woff2",
"weight": 400,
"license": "GPL-3.0-or-later WITH Font-exception-2.0"
},
{
"family": "FandolSong",
"aliases": ["Noto Serif CJK SC", "Source Han Serif SC", "SimSun"],
"source": "theme-shared:official-fonts/FandolSong-Bold.woff2",
"weight": 700,
"license": "GPL-3.0-or-later WITH Font-exception-2.0"
},
{
"family": "FandolHei",
"aliases": ["Noto Sans CJK SC", "Source Han Sans SC", "SimHei", "Microsoft YaHei"],
"source": "theme-shared:official-fonts/FandolHei-Regular.woff2",
"weight": 400,
"license": "GPL-3.0-or-later WITH Font-exception-2.0"
}
]
},
"docxStyle": {
"preset": "formal"
},