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 appVersion = JSON.parse( fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8") ).version; 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 inlineCodeContinuityProbes = [ { id: "paragraph", code: "mdtp_ic_p", text: "段落前mdtp_ic_p段落后" }, { id: "list-item", code: "mdtp_ic_l", text: "列表前mdtp_ic_l列表后" }, { id: "table-cell", code: "mdtp_ic_c", text: "单元格前mdtp_ic_c单元格后" } ]; const inlineCodeContinuityMarkdown = ` ## 行内代码连续性门禁 段落前\`mdtp_ic_p\`段落后 - 列表前\`mdtp_ic_l\`列表后 | 门禁 | 内容 | | --- | --- | | 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | `; 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 newestModifiedAt(targetPath) { const stat = fs.statSync(targetPath); if (stat.isFile()) { return stat.mtimeMs; } return fs.readdirSync(targetPath, { withFileTypes: true }).reduce( (newest, entry) => Math.max( newest, newestModifiedAt(path.join(targetPath, entry.name)) ), stat.mtimeMs ); } function assertFreshArtifact(label, sourcePaths, artifactPath) { assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`); const newestSource = Math.max(...sourcePaths.map(newestModifiedAt)); const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs; assert( artifactModifiedAt + 1_500 >= newestSource, `${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁` ); } 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(/ match[1]); const embeddedFontNames = [ ...fontTableXml.matchAll(//gu) ].flatMap((match) => /)[\s\S]*?<\/w:p>/gu) ].map((match) => match[0]); const authorParagraphCount = paragraphs.filter((paragraph) => //u.test(paragraph) ).length; const officialIssueRows = paragraphs.filter((paragraph) => //u.test( paragraph ) ); const officialIssueRowsWithRightTab = officialIssueRows.filter( (paragraph) => [...paragraph.matchAll(/]*)?\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(//gu)?.length ?? 0), 0 ); const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => { const matchingParagraphs = paragraphs.filter((paragraph) => paragraph.includes(probe.code) ); const paragraph = matchingParagraphs[0] ?? ""; const text = [...paragraph.matchAll(/]*)?>([\s\S]*?)<\/w:t>/gu)] .map((match) => match[1]) .join(""); const codeRunPattern = new RegExp( `)[\\s\\S]*?[\\s\\S]*?]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`, "u" ); const hardBreakCount = paragraph.match(/]*)?\s*\/>/gu)?.length ?? 0; return { id: probe.id, code: probe.code, expectedText: probe.text, actualText: text, paragraphCount: matchingParagraphs.length, hardBreakCount, characterStylePresent: codeRunPattern.test(paragraph), passed: matchingParagraphs.length === 1 && text === probe.text && hardBreakCount === 0 && codeRunPattern.test(paragraph) }; }); return { bytes: content.byteLength, sha256: sha256(content), partCount: Object.keys(entries).length, sectionCount: documentXml.match(/)/gu)?.length ?? 0, embeddedFontPartCount: fontParts.length, fontNames: [...new Set(fontNames)].sort((first, second) => first.localeCompare(second, "en") ), embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) => first.localeCompare(second, "en") ), optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"), hasAltChunk: /)/u.test(documentXml), internalMarkerCount: documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0, authorParagraphCount, officialIssueRowCount: officialIssueRows.length, officialIssueRowRightTabCount: officialIssueRowsWithRightTab.length, officialIssueRowContentTabCount, inlineCodeContinuity, inlineCodeContinuityPassed: inlineCodeContinuity.every( (probe) => probe.passed ), 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 主题矩阵报告"); assertFreshArtifact( "Preview Engine", [path.join(repositoryDirectory, "packages", "preview-engine", "src")], path.join( repositoryDirectory, "packages", "preview-engine", "dist", "semantic-cover-fit.js" ) ); assertFreshArtifact( "DOCX Engine", [path.join(repositoryDirectory, "packages", "docx-engine", "src")], path.join( repositoryDirectory, "packages", "docx-engine", "dist", "document-structure-transform.js" ) ); assertFreshArtifact( "Web Preview Runtime", [ path.join(repositoryDirectory, "apps", "web", "src"), path.join( repositoryDirectory, "packages", "preview-engine", "dist", "semantic-cover-fit.js" ) ], path.join(webDirectory, "preview-frame.html") ); 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 captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter); let capturedDocxDocumentLayout; docxMediaAdapter.capture = async (...args) => { const output = await captureDocxMedia(...args); capturedDocxDocumentLayout = structuredClone( output.plan.documentLayout ?? { tables: [] } ); return output; }; const app = buildApp({ logger: { level: "error" }, pdfGenerator, docxMediaAdapter, fontPacks: { roots: [fontPackRoot], appVersion } }); 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) { capturedDocxDocumentLayout = undefined; 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").trimEnd()}${inlineCodeContinuityMarkdown}`; 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 样式` ); assert( inspection.inlineCodeContinuityPassed, `主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}` ); 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, documentLayout: capturedDocxDocumentLayout ?? { tables: [] }, diagnostics: responseDiagnostics(docx.response, "docx") }, standardInspection: standard.inspection, expectsOfficialDualEndedRow }); } } finally { await app.close(); } const enhancedThemes = results.filter( (result) => result.docx.inspection.optionalFontPackApplied ); 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`);