diff --git a/apps/desktop/src/pdf-contract.ts b/apps/desktop/src/pdf-contract.ts index 9c293dd..60e1056 100644 --- a/apps/desktop/src/pdf-contract.ts +++ b/apps/desktop/src/pdf-contract.ts @@ -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, diff --git a/apps/desktop/tests/electron-docx-media-capture.test.ts b/apps/desktop/tests/electron-docx-media-capture.test.ts index a8fba5b..c66ef8e 100644 --- a/apps/desktop/tests/electron-docx-media-capture.test.ts +++ b/apps/desktop/tests/electron-docx-media-capture.test.ts @@ -15,6 +15,7 @@ const plan: DocxMediaCapturePlan = { ordinal: 1, kindOrdinal: 1, altText: "图片", + alignment: "center", displayWidthPx: 200, displayHeightPx: 100, captureX: 12, diff --git a/apps/desktop/tests/electron-docx-theme-style.test.ts b/apps/desktop/tests/electron-docx-theme-style.test.ts index c4d2be5..740c125 100644 --- a/apps/desktop/tests/electron-docx-theme-style.test.ts +++ b/apps/desktop/tests/electron-docx-theme-style.test.ts @@ -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", diff --git a/apps/server/package.json b/apps/server/package.json index 9c4417c..688c0ad 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" }, diff --git a/apps/server/scripts/verify-docx-http.mjs b/apps/server/scripts/verify-docx-http.mjs index da83f53..6985b9f 100644 --- a/apps/server/scripts/verify-docx-http.mjs +++ b/apps/server/scripts/verify-docx-http.mjs @@ -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", diff --git a/apps/server/scripts/verify-docx-r4-matrix.mjs b/apps/server/scripts/verify-docx-r4-matrix.mjs new file mode 100644 index 0000000..8d3f175 --- /dev/null +++ b/apps/server/scripts/verify-docx-r4-matrix.mjs @@ -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(/ match[1]); + const paragraphs = [ + ...documentXml.matchAll(/)[\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 + ); + 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") + ), + 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, + 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`); diff --git a/apps/server/tests/playwright-docx-media-capture.test.ts b/apps/server/tests/playwright-docx-media-capture.test.ts index fa2b86c..b798135 100644 --- a/apps/server/tests/playwright-docx-media-capture.test.ts +++ b/apps/server/tests/playwright-docx-media-capture.test.ts @@ -15,6 +15,7 @@ const plan: DocxMediaCapturePlan = { ordinal: 1, kindOrdinal: 1, altText: "图表", + alignment: "center", displayWidthPx: 320, displayHeightPx: 180, captureX: 10, diff --git a/apps/server/tests/playwright-docx-theme-style.test.ts b/apps/server/tests/playwright-docx-theme-style.test.ts index ffdc21c..f267cd7 100644 --- a/apps/server/tests/playwright-docx-theme-style.test.ts +++ b/apps/server/tests/playwright-docx-theme-style.test.ts @@ -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", diff --git a/apps/web/src/ExportSettingsDrawer.tsx b/apps/web/src/ExportSettingsDrawer.tsx index d3ff7ba..7cbb5ce 100644 --- a/apps/web/src/ExportSettingsDrawer.tsx +++ b/apps/web/src/ExportSettingsDrawer.tsx @@ -731,6 +731,19 @@ export function ExportSettingsDrawer({ ); })} +