建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
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`);
|