From 58f87cc19fd542dc75c27ccdbeefa7e0b892db04 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sun, 2 Aug 2026 03:27:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20DOCX=20R4=20?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E7=BA=A7=E8=A7=86=E8=A7=89=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。 --- apps/desktop/src/pdf-contract.ts | 4 + .../tests/electron-docx-media-capture.test.ts | 1 + .../tests/electron-docx-theme-style.test.ts | 1 + apps/server/package.json | 2 + apps/server/scripts/verify-docx-http.mjs | 1 + apps/server/scripts/verify-docx-r4-matrix.mjs | 489 +++++ .../playwright-docx-media-capture.test.ts | 1 + .../tests/playwright-docx-theme-style.test.ts | 1 + apps/web/src/ExportSettingsDrawer.tsx | 15 +- apps/web/tests/export-settings.test.ts | 4 +- apps/web/tests/theme-margins.test.ts | 4 +- docs/PROGRESS.md | 66 +- package-lock.json | 1 + package.json | 4 + .../application/tests/bundled-themes.test.ts | 34 + .../tests/docx-media-service.test.ts | 1 + packages/core/src/document.ts | 2 + packages/core/src/docx.ts | 11 + packages/core/src/export-config.ts | 6 +- packages/core/src/semantic-document.ts | 2 + packages/core/tests/document.test.ts | 1 + packages/core/tests/docx.test.ts | 2 + packages/core/tests/export-config.test.ts | 44 +- packages/core/tests/theme.test.ts | 39 + packages/document-visual-diff/src/compare.ts | 239 ++- packages/document-visual-diff/src/index.ts | 2 + .../src/office-adapter.ts | 328 +++- .../src/page-semantics.ts | 331 ++++ .../src/paragraph-layout.ts | 405 ++++ packages/document-visual-diff/src/raster.ts | 59 +- packages/document-visual-diff/src/report.ts | 203 +- packages/document-visual-diff/src/text.ts | 74 + packages/document-visual-diff/src/types.ts | 145 +- .../tests/adapters.test.ts | 28 + .../tests/compare.test.ts | 101 + .../tests/paragraph-layout.test.ts | 275 +++ .../document-visual-diff/tests/raster.test.ts | 37 + .../document-visual-diff/tests/report.test.ts | 401 ++++ .../document-visual-diff/tests/text.test.ts | 11 + packages/docx-engine/README.md | 2 +- .../docx-engine/assets/docx-media-filter.lua | 6 +- .../docx-engine/fixtures/docx-media-visual.md | 52 + .../scripts/verify-docx-acceptance.mjs | 158 +- .../scripts/verify-pandoc-conversion.mjs | 1 + .../scripts/verify-theme-docx-matrix.mjs | 25 +- .../docx-engine/src/acceptance-validator.ts | 46 +- .../src/document-structure-transform.ts | 1658 +++++++++++++++-- .../src/header-footer-transform.ts | 190 +- packages/docx-engine/src/index.ts | 2 + packages/docx-engine/src/media-acceptance.ts | 452 +++++ packages/docx-engine/src/ooxml.ts | 15 + .../src/page-decoration-acceptance.ts | 440 +++++ packages/docx-engine/src/pandoc-converter.ts | 4 +- packages/docx-engine/src/pandoc-media.ts | 39 +- packages/docx-engine/src/pandoc-structure.ts | 8 + packages/docx-engine/src/reference-builder.ts | 7 + packages/docx-engine/src/reference-options.ts | 2 + packages/docx-engine/src/section-transform.ts | 10 +- packages/docx-engine/src/styles-transform.ts | 145 +- packages/docx-engine/src/token-style-map.ts | 10 + .../tests/acceptance-validator.test.ts | 133 +- .../document-structure-transform.test.ts | 429 ++++- .../tests/media-acceptance.test.ts | 272 +++ .../docx-engine/tests/pandoc-media.test.ts | 9 + .../tests/pandoc-structure.test.ts | 8 + .../tests/reference-builder.test.ts | 238 ++- packages/docx-theme-engine/README.md | 4 +- packages/docx-theme-engine/src/collector.ts | 8 + packages/docx-theme-engine/src/normalizer.ts | 73 +- packages/docx-theme-engine/src/probe.ts | 6 +- packages/docx-theme-engine/src/runtime.ts | 42 + packages/docx-theme-engine/src/slots.ts | 8 + packages/docx-theme-engine/src/snapshot.ts | 4 + packages/docx-theme-engine/src/tokens.ts | 8 + .../docx-theme-engine/tests/contracts.test.ts | 1 + .../tests/normalizer.test.ts | 65 +- .../docx-theme-engine/tests/probe.test.ts | 4 + .../docx-theme-engine/tests/runtime.test.ts | 5 + .../preview-engine/src/docx-media-runtime.ts | 29 + packages/preview-engine/src/index.ts | 3 + .../src/paged-document-runtime.ts | 59 +- .../src/paged-page-decorations.ts | 107 ++ .../preview-engine/src/paged-page-sequence.ts | 110 ++ packages/preview-engine/src/paged-preview.ts | 18 + .../preview-engine/src/semantic-cover-fit.ts | 33 + .../tests/docx-media-runtime.test.ts | 56 +- .../tests/paged-page-decorations.test.ts | 123 ++ .../tests/paged-page-sequence.test.ts | 122 ++ .../tests/paged-preview.test.ts | 45 + .../tests/semantic-cover-fit.test.ts | 39 + .../renderer/src/render-document-structure.ts | 35 +- .../renderer/tests/render-markdown.test.ts | 6 + .../semantic-document/src/model-builder.ts | 12 +- .../tests/model-builder.test.ts | 3 + scripts/create-pdf-snapshot-artifact.mjs | 93 + scripts/verify-docx-layout-visual-matrix.mjs | 629 +++++++ scripts/verify-docx-media-visuals.mjs | 761 ++++++++ .../verify-docx-page-decoration-visuals.mjs | 519 ++++++ themes/formal-feasibility/theme.css | 4 +- themes/formal-feasibility/theme.json | 25 + 100 files changed, 10409 insertions(+), 386 deletions(-) create mode 100644 apps/server/scripts/verify-docx-r4-matrix.mjs create mode 100644 packages/document-visual-diff/src/page-semantics.ts create mode 100644 packages/document-visual-diff/src/paragraph-layout.ts create mode 100644 packages/document-visual-diff/tests/paragraph-layout.test.ts create mode 100644 packages/docx-engine/fixtures/docx-media-visual.md create mode 100644 packages/docx-engine/src/media-acceptance.ts create mode 100644 packages/docx-engine/src/page-decoration-acceptance.ts create mode 100644 packages/docx-engine/tests/media-acceptance.test.ts create mode 100644 packages/preview-engine/src/paged-page-decorations.ts create mode 100644 packages/preview-engine/src/paged-page-sequence.ts create mode 100644 packages/preview-engine/src/semantic-cover-fit.ts create mode 100644 packages/preview-engine/tests/paged-page-decorations.test.ts create mode 100644 packages/preview-engine/tests/paged-page-sequence.test.ts create mode 100644 packages/preview-engine/tests/semantic-cover-fit.test.ts create mode 100644 scripts/create-pdf-snapshot-artifact.mjs create mode 100644 scripts/verify-docx-layout-visual-matrix.mjs create mode 100644 scripts/verify-docx-media-visuals.mjs create mode 100644 scripts/verify-docx-page-decoration-visuals.mjs 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({ ); })} +