feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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`);
|
||||
Reference in New Issue
Block a user