From 2c5c1bd3177de8811b31a9f18a52d9592ac03a55 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Tue, 4 Aug 2026 10:30:44 +0800 Subject: [PATCH] =?UTF-8?q?release:=20=E5=8F=91=E5=B8=83=20v0.6.1=20DOCX?= =?UTF-8?q?=20=E8=A7=86=E8=A7=89=E4=B8=80=E8=87=B4=E6=80=A7=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。 --- .dockerignore | 19 + apps/desktop/README.md | 4 +- apps/desktop/package.json | 2 +- .../src/electron-docx-media-capture.ts | 26 +- .../tests/electron-docx-media-capture.test.ts | 24 +- apps/desktop/tests/release-build.test.ts | 2 +- apps/server/scripts/verify-docx-r4-matrix.mjs | 86 +- apps/server/src/index.ts | 2 +- .../src/playwright-docx-media-capture.ts | 1 + apps/server/tests/docker-deployment.test.ts | 11 +- apps/server/tests/docx-media-engine.test.ts | 2 + .../playwright-docx-media-capture.test.ts | 2 + apps/web/src/paged-preview-frame.ts | 4 +- apps/web/tests/app-version.test.ts | 4 +- deploy/Dockerfile | 3 +- deploy/README.md | 28 +- deploy/compose.yaml | 2 +- deploy/verify-font-pack-compose.mjs | 29 +- docs/PANDOC_DISTRIBUTION.md | 4 +- docs/PROGRESS.md | 38 +- docs/V0.6.1_DESIGN.md | 21 + docs/releases/v0.6.1.md | 49 + font-packs/README.md | 11 + .../assets/mdtp-mono/1.0.0/MdTPMono-Bold.ttf | 3 + .../mdtp-mono/1.0.0/MdTPMono-Bold.woff2 | 3 + .../mdtp-mono/1.0.0/MdTPMono-Regular.ttf | 3 + .../mdtp-mono/1.0.0/MdTPMono-Regular.woff2 | 3 + font-packs/bundle.json | 6 + .../recipes/mdtp-mono/OFL-CascadiaCode.txt | 94 ++ font-packs/recipes/mdtp-mono/SOURCE.md | 33 + font-packs/recipes/mdtp-mono/recipe.json | 57 ++ package-lock.json | 7 +- package.json | 2 +- packages/application/package.json | 1 + .../application/src/docx-media-service.ts | 3 +- .../src/docx-theme-token-service.ts | 5 +- packages/application/src/theme-registry.ts | 56 +- .../tests/application-service.test.ts | 55 +- packages/core/src/docx.ts | 103 +- packages/document-visual-diff/src/compare.ts | 36 +- .../src/paragraph-layout.ts | 251 ++++- packages/document-visual-diff/src/raster.ts | 191 +++- packages/document-visual-diff/src/report.ts | 2 +- .../src/semantic-block.ts | 152 ++- packages/document-visual-diff/src/text.ts | 5 + packages/document-visual-diff/src/types.ts | 19 + .../tests/compare.test.ts | 32 + .../tests/paragraph-layout.test.ts | 113 ++ .../document-visual-diff/tests/raster.test.ts | 29 + .../document-visual-diff/tests/report.test.ts | 101 ++ .../tests/semantic-block.test.ts | 198 ++++ .../document-visual-diff/tests/text.test.ts | 1 + .../src/document-structure-transform.ts | 963 +++++++++++++++--- packages/docx-engine/src/pandoc-converter.ts | 5 +- packages/docx-engine/src/pandoc-media.ts | 9 +- packages/docx-engine/src/pandoc-structure.ts | 8 + packages/docx-engine/src/styles-transform.ts | 102 +- packages/docx-engine/src/token-style-map.ts | 95 +- .../document-structure-transform.test.ts | 281 ++++- .../tests/font-package-transform.test.ts | 2 +- .../tests/pandoc-converter.test.ts | 3 + .../tests/pandoc-structure.test.ts | 30 + .../tests/reference-builder.test.ts | 29 + .../docx-engine/tests/token-style-map.test.ts | 27 + packages/docx-theme-engine/src/normalizer.ts | 102 +- packages/docx-theme-engine/src/probe.ts | 6 +- packages/docx-theme-engine/src/runtime.ts | 1 - packages/docx-theme-engine/src/slots.ts | 32 + .../tests/normalizer.test.ts | 71 +- .../docx-theme-engine/tests/probe.test.ts | 2 + .../docx-theme-engine/tests/runtime.test.ts | 1 + .../preview-engine/src/docx-media-runtime.ts | 439 +++++++- .../preview-engine/src/highlight-styles.ts | 19 + packages/preview-engine/src/index.ts | 1 + .../src/paged-document-runtime.ts | 43 +- packages/preview-engine/src/paged-preview.ts | 6 +- .../tests/docx-media-runtime.test.ts | 312 +++++- .../tests/paged-preview.test.ts | 4 + scripts/build-font-packs.mjs | 2 +- scripts/docx-layout-visual-matrix-cases.mjs | 4 +- .../docx-layout-visual-matrix-cases.test.mjs | 14 + scripts/stage-release.mjs | 29 + scripts/stage-release.test.mjs | 17 + scripts/verify-docx-layout-visual-matrix.mjs | 151 ++- 84 files changed, 4404 insertions(+), 344 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/releases/v0.6.1.md create mode 100644 font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.ttf create mode 100644 font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.woff2 create mode 100644 font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.ttf create mode 100644 font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.woff2 create mode 100644 font-packs/recipes/mdtp-mono/OFL-CascadiaCode.txt create mode 100644 font-packs/recipes/mdtp-mono/SOURCE.md create mode 100644 font-packs/recipes/mdtp-mono/recipe.json create mode 100644 packages/document-visual-diff/tests/semantic-block.test.ts create mode 100644 packages/preview-engine/src/highlight-styles.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f4f7ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.local +.vscode +apps/desktop +coverage +dist +node_modules +out +output/* +!output/font-packs/ +output/font-packs/* +!output/font-packs/root/ +!output/font-packs/root/** +playwright-report +test-results +tmp +*.log +*.tsbuildinfo diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 07501aa..0d26372 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -57,8 +57,8 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop ``` 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 -`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以 -`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 +`apps/desktop/out/v0.6.1/`。该目录被 Git 忽略。公司内部分发以 +`MorphDoc-0.6.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 提示。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d528bf2..687c652 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@md-to-pdf/desktop", - "version": "0.6.0", + "version": "0.6.1", "private": true, "productName": "MorphDoc", "description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具", diff --git a/apps/desktop/src/electron-docx-media-capture.ts b/apps/desktop/src/electron-docx-media-capture.ts index 69df713..b3c8dab 100644 --- a/apps/desktop/src/electron-docx-media-capture.ts +++ b/apps/desktop/src/electron-docx-media-capture.ts @@ -72,13 +72,6 @@ export async function captureDocxMediaWithElectronWebContents( signal?: AbortSignal, timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS ) { - signal?.throwIfAborted(); - const plan = (await withTimeout( - webContents.executeJavaScript(createRenderScript(request), true), - timeouts.renderMs, - `桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`, - signal - )) as DocxMediaCapturePlan; signal?.throwIfAborted(); const ownsDebugger = !webContents.debugger.isAttached(); if (ownsDebugger) { @@ -86,6 +79,16 @@ export async function captureDocxMediaWithElectronWebContents( } try { + await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", { + media: "print" + }); + const plan = (await withTimeout( + webContents.executeJavaScript(createRenderScript(request), true), + timeouts.renderMs, + `桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`, + signal + )) as DocxMediaCapturePlan; + signal?.throwIfAborted(); const captures = []; for (const target of plan.targets) { signal?.throwIfAborted(); @@ -117,8 +120,13 @@ export async function captureDocxMediaWithElectronWebContents( signal?.throwIfAborted(); return { plan, captures }; } finally { - if (ownsDebugger && webContents.debugger.isAttached()) { - webContents.debugger.detach(); + if (webContents.debugger.isAttached()) { + await webContents.debugger + .sendCommand("Emulation.setEmulatedMedia", { media: "screen" }) + .catch(() => undefined); + if (ownsDebugger && webContents.debugger.isAttached()) { + webContents.debugger.detach(); + } } } } diff --git a/apps/desktop/tests/electron-docx-media-capture.test.ts b/apps/desktop/tests/electron-docx-media-capture.test.ts index fa35032..3c98b14 100644 --- a/apps/desktop/tests/electron-docx-media-capture.test.ts +++ b/apps/desktop/tests/electron-docx-media-capture.test.ts @@ -95,6 +95,14 @@ describe("Electron DOCX 媒体捕获", () => { png: Buffer.from("png") }); expect(attach).toHaveBeenCalledWith("1.3"); + expect(sendCommand).toHaveBeenCalledWith( + "Emulation.setEmulatedMedia", + { media: "print" } + ); + expect(sendCommand).toHaveBeenCalledWith( + "Emulation.setEmulatedMedia", + { media: "screen" } + ); expect(sendCommand).toHaveBeenCalledWith( "Page.captureScreenshot", expect.objectContaining({ @@ -106,10 +114,18 @@ describe("Electron DOCX 媒体捕获", () => { }); it("媒体布局永久不返回时报告局部超时", async () => { + let attached = false; const webContents = { executeJavaScript: vi.fn(() => new Promise(() => undefined)), debugger: { - isAttached: vi.fn(() => false) + isAttached: vi.fn(() => attached), + attach: vi.fn(() => { + attached = true; + }), + detach: vi.fn(() => { + attached = false; + }), + sendCommand: vi.fn(async () => undefined) } } as unknown as WebContents; @@ -136,7 +152,11 @@ describe("Electron DOCX 媒体捕获", () => { attached = true; }), detach, - sendCommand: vi.fn(() => new Promise(() => undefined)) + sendCommand: vi.fn((method: string) => + method === "Page.captureScreenshot" + ? new Promise(() => undefined) + : Promise.resolve(undefined) + ) } } as unknown as WebContents; diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts index e01c191..d4019bf 100644 --- a/apps/desktop/tests/release-build.test.ts +++ b/apps/desktop/tests/release-build.test.ts @@ -156,7 +156,7 @@ describe("桌面发行构建链", () => { artifactPrefix: "MorphDoc" }); expect(desktopManifest).toMatchObject({ - version: "0.6.0", + version: "0.6.1", productName: "MorphDoc" }); expect(builderConfig).toContain("appId: brand.appId"); diff --git a/apps/server/scripts/verify-docx-r4-matrix.mjs b/apps/server/scripts/verify-docx-r4-matrix.mjs index 6491835..568cc9d 100644 --- a/apps/server/scripts/verify-docx-r4-matrix.mjs +++ b/apps/server/scripts/verify-docx-r4-matrix.mjs @@ -11,6 +11,9 @@ 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"); @@ -32,6 +35,37 @@ const fontPackRoot = path.resolve( ); 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"], @@ -226,6 +260,35 @@ function inspectDocx(content) { 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), @@ -247,6 +310,10 @@ function inspectDocx(content) { officialIssueRowRightTabCount: officialIssueRowsWithRightTab.length, officialIssueRowContentTabCount, + inlineCodeContinuity, + inlineCodeContinuityPassed: inlineCodeContinuity.every( + (probe) => probe.passed + ), requiredStylesPresent: [ "Normal", "Heading1", @@ -366,13 +433,22 @@ 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: "0.6.0" + appVersion } }); registerWebRuntime(app); @@ -390,10 +466,11 @@ try { ); 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"); + const markdown = `${fs.readFileSync(samplePath, "utf8").trimEnd()}${inlineCodeContinuityMarkdown}`; const exportConfig = mergeExportConfig({ ...defaultExportConfig, name: `${theme.name} R4 生产链验收`, @@ -449,6 +526,10 @@ try { 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( @@ -504,6 +585,7 @@ try { docx: { outputFile: path.relative(repositoryDirectory, docxPath), inspection, + documentLayout: capturedDocxDocumentLayout ?? { tables: [] }, diagnostics: responseDiagnostics(docx.response, "docx") }, standardInspection: standard.inspection, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0307e25..d7359a3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,7 +10,7 @@ const app = buildApp({ ? { fontPacks: { roots: [fontPackRoot], - appVersion: process.env.APP_VERSION ?? "0.6.0" + appVersion: process.env.APP_VERSION ?? "0.6.1" } } : {}) diff --git a/apps/server/src/playwright-docx-media-capture.ts b/apps/server/src/playwright-docx-media-capture.ts index 57c6f90..8527662 100644 --- a/apps/server/src/playwright-docx-media-capture.ts +++ b/apps/server/src/playwright-docx-media-capture.ts @@ -38,6 +38,7 @@ export async function captureDocxMediaWithPlaywrightPage( signal?: AbortSignal ) { signal?.throwIfAborted(); + await page.emulateMedia({ media: "print" }); const plan = await renderCapturePlan(page, request); signal?.throwIfAborted(); const session = await page.context().newCDPSession(page); diff --git a/apps/server/tests/docker-deployment.test.ts b/apps/server/tests/docker-deployment.test.ts index c8f0ee7..13376a6 100644 --- a/apps/server/tests/docker-deployment.test.ts +++ b/apps/server/tests/docker-deployment.test.ts @@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) { return readFileSync(`${projectRoot}/${relativePath}`, "utf8"); } -describe("Docker v0.6.0 部署契约", () => { +describe("Docker v0.6.1 部署契约", () => { it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => { const dockerfile = readProjectFile("deploy/Dockerfile"); const workspaces = [ @@ -57,6 +57,9 @@ describe("Docker v0.6.0 部署契约", () => { expect(dockerfile).toContain( "COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2" ); + expect(dockerfile).toContain( + "ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc" + ); expect(dockerfile).toContain( 'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"' ); @@ -65,7 +68,8 @@ describe("Docker v0.6.0 部署契约", () => { it("将受校验字体包内置到镜像并取消运行时字体挂载", () => { const dockerfile = readProjectFile("deploy/Dockerfile"); const compose = readProjectFile("deploy/compose.yaml"); - const dockerignore = readProjectFile( + const dockerignore = readProjectFile(".dockerignore"); + const dockerfileIgnore = readProjectFile( "deploy/Dockerfile.dockerignore" ); @@ -78,10 +82,11 @@ describe("Docker v0.6.0 部署契约", () => { ); expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs"); expect(compose).toContain( - 'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"' + 'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}"' ); expect(dockerignore.split(/\r?\n/u)).toContain(".local"); expect(dockerignore).toContain("!output/font-packs/root/**"); + expect(dockerfileIgnore).toBe(dockerignore); }); it("提供显式真实 Docker 字体包验收命令", () => { diff --git a/apps/server/tests/docx-media-engine.test.ts b/apps/server/tests/docx-media-engine.test.ts index b11ae38..b4a45ce 100644 --- a/apps/server/tests/docx-media-engine.test.ts +++ b/apps/server/tests/docx-media-engine.test.ts @@ -46,6 +46,7 @@ describe("Server DOCX 媒体浏览器", () => { const detach = vi.fn(async () => undefined); const page = { goto: vi.fn(async () => undefined), + emulateMedia: vi.fn(async () => undefined), waitForFunction: vi.fn(async () => undefined), evaluate: vi.fn(async () => emptyPlan), context: () => context @@ -104,6 +105,7 @@ describe("Server DOCX 媒体浏览器", () => { "http://renderer.test/preview-frame.html?target=continuous", { waitUntil: "domcontentloaded" } ); + expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" }); expect(continueRoute).toHaveBeenCalledTimes(1); expect(abortRoute).toHaveBeenCalledWith("blockedbyclient"); expect(detach).toHaveBeenCalledOnce(); diff --git a/apps/server/tests/playwright-docx-media-capture.test.ts b/apps/server/tests/playwright-docx-media-capture.test.ts index b798135..f3bad0f 100644 --- a/apps/server/tests/playwright-docx-media-capture.test.ts +++ b/apps/server/tests/playwright-docx-media-capture.test.ts @@ -62,6 +62,7 @@ describe("Playwright DOCX 媒体捕获", () => { data: Buffer.from("png").toString("base64") })); const page = { + emulateMedia: vi.fn(async () => undefined), evaluate: vi.fn(async () => plan), context: () => ({ newCDPSession: vi.fn(async () => ({ send, detach })) @@ -74,6 +75,7 @@ describe("Playwright DOCX 媒体捕获", () => { ); expect(result.plan).toBe(plan); + expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" }); expect(result.captures[0]).toEqual({ id: "docx-media-1", png: Buffer.from("png") diff --git a/apps/web/src/paged-preview-frame.ts b/apps/web/src/paged-preview-frame.ts index e85a72e..69e4687 100644 --- a/apps/web/src/paged-preview-frame.ts +++ b/apps/web/src/paged-preview-frame.ts @@ -3,13 +3,13 @@ import { isPagedPreviewRenderRequest, PAGED_PREVIEW_MESSAGE_SCOPE, PagedDocumentRuntime, + SHARED_HIGHLIGHT_CSS, renderDocxMediaCapturePlan, resolveMermaidOutputMode, resolvePagedRenderTarget, type PagedPreviewFrameMessage, type PagedPreviewPayload } from "@md-to-pdf/preview-engine"; -import highlightCss from "highlight.js/styles/github.css?inline"; import katexCss from "katex/dist/katex.min.css?inline"; import { beginPreviewFrameRender, @@ -29,7 +29,7 @@ const previewRoot = getPreviewRoot(); const renderTarget = resolvePagedRenderTarget(window.location.search); const mermaidOutput = resolveMermaidOutputMode(window.location.search); const runtime = new PagedDocumentRuntime(previewRoot, { - highlightCss, + highlightCss: SHARED_HIGHLIGHT_CSS, katexCss, echartsCss }); diff --git a/apps/web/tests/app-version.test.ts b/apps/web/tests/app-version.test.ts index 958b793..3617500 100644 --- a/apps/web/tests/app-version.test.ts +++ b/apps/web/tests/app-version.test.ts @@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url)); describe("应用版本", () => { it("从统一构建版本生成标题徽标", () => { - expect(APP_VERSION).toBe("0.6.0"); - expect(APP_VERSION_LABEL).toBe("v0.6.0"); + expect(APP_VERSION).toBe("0.6.1"); + expect(APP_VERSION_LABEL).toBe("v0.6.1"); }); it("允许浏览器加载同源 Web Manifest", () => { diff --git a/deploy/Dockerfile b/deploy/Dockerfile index f294684..fba3de3 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -110,7 +110,7 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \ fi COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2 -RUN ln -s /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \ +RUN ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \ && test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2" WORKDIR /app @@ -167,6 +167,7 @@ ENV HOST=127.0.0.1 \ RUN test -f /app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json RUN test -f /app/.local/font-packs/mdtp-sans-sc/1.0.0/font-pack.json +RUN test -f /app/.local/font-packs/mdtp-mono/1.0.0/font-pack.json USER pwuser diff --git a/deploy/README.md b/deploy/README.md index 11d063e..7608f54 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,7 +3,7 @@ 本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将 `/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本 Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成 -可编辑 DOCX。可选字体包只从宿主机只读挂载,不进入应用镜像。 +可编辑 DOCX。发行镜像固定内置受校验字体包,不依赖宿主机字体挂载。 ## 启动 @@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down 可以直接指定版本化镜像名称构建: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' docker compose -f deploy\compose.yaml build ``` @@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0' -$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' +$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' docker compose -f deploy\compose.yaml build ``` -复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.0 应用代码和生产 +复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.1 应用代码和生产 依赖。正式跨机器构建仍建议使用默认完整路径。 ## 主题挂载 @@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d ## 内置字体包 -v0.6.0 的发行镜像固定将受校验字体包内置到 +v0.6.1 的发行镜像固定将受校验字体包内置到 `/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端 无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。 @@ -84,11 +84,9 @@ v0.6.0 的发行镜像固定将受校验字体包内置到 ```text <字体包根目录>/ -└── mdtp-serif-sc/ - └── 1.0.0/ - ├── font-pack.json - ├── LICENSE.txt - └── fonts/ +├── mdtp-serif-sc/1.0.0/ +├── mdtp-sans-sc/1.0.0/ +└── mdtp-mono/1.0.0/ ``` 从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像: @@ -98,15 +96,15 @@ npm run build:font-pack docker compose -f deploy\compose.yaml up -d ``` -字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的 -`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器 +字体二进制通过 Git LFS 固定在仓库中。Docker 构建上下文只放行构建所需 +源码与本次生成的 `output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器 仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时 必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。 发布前执行真实内置字体门禁: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1' npm run verify:docker-font-pack ``` @@ -124,7 +122,7 @@ npm run verify:docker-font-pack | `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium | | `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 | | `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 | -| `MD_TO_PDF_APP_VERSION` | `0.6.0` | 字体包兼容性判断使用的应用版本 | +| `MD_TO_PDF_APP_VERSION` | `0.6.1` | 字体包兼容性判断使用的应用版本 | | `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 | | `PDF_MAX_QUEUE` | `4` | 等待队列上限 | | `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 | diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 8b6202e..012b3ff 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -19,7 +19,7 @@ services: PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}" PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}" PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}" - APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}" + APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}" FONT_PACK_ROOT: /app/.local/font-packs volumes: - "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro" diff --git a/deploy/verify-font-pack-compose.mjs b/deploy/verify-font-pack-compose.mjs index e3cf7da..7d5268d 100644 --- a/deploy/verify-font-pack-compose.mjs +++ b/deploy/verify-font-pack-compose.mjs @@ -17,7 +17,8 @@ const fontPackRoot = path.resolve( ); const expectedFontPacks = [ { id: "mdtp-serif-sc", version: "1.0.0" }, - { id: "mdtp-sans-sc", version: "1.0.0" } + { id: "mdtp-sans-sc", version: "1.0.0" }, + { id: "mdtp-mono", version: "1.0.0" } ]; function assert(condition, message) { @@ -101,7 +102,7 @@ async function exportDocx(origin) { headers: { "content-type": "application/json" }, body: JSON.stringify({ markdown: - "---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段可编辑中文正文。", + "---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。", fileName: "Docker 内置字体包验收.md", language: "zh-CN", resources: [], @@ -174,7 +175,7 @@ const environment = { ...buildEnvironment, MD_TO_PDF_PORT: String(port), MD_TO_PDF_THEME_DIR: themeRoot, - MD_TO_PDF_APP_VERSION: "0.6.0" + MD_TO_PDF_APP_VERSION: "0.6.1" }; const compose = [ "compose", @@ -214,10 +215,13 @@ try { ); const css = await cssResponse.text(); assert(cssResponse.ok, "主题 CSS 请求失败"); - assert( - css.includes("md-to-pdf-font-packs:mdtp-serif-sc@1.0.0"), - "Docker 内置字体包未进入主题 CSS" - ); + assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记"); + for (const pack of expectedFontPacks) { + assert( + css.includes(`${pack.id}@${pack.version}`), + `Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}` + ); + } const assetPath = css.match( /(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u )?.[1]; @@ -241,6 +245,17 @@ try { assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`); const fontTable = strFromU8(entries["word/fontTable.xml"]); assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表"); + assert(fontTable.includes("MdTP Mono"), "等宽字体包未进入 DOCX 字体表"); + const documentXml = strFromU8(entries["word/document.xml"]); + const inlineCodeParagraph = documentXml + .match(/]*)?>[\s\S]*?<\/w:p>/gu) + ?.find( + (paragraph) => + paragraph.includes("这是一段包含行内代码") && + paragraph.includes("const value = 1") && + paragraph.includes("代码前后不得换行") + ); + assert(inlineCodeParagraph, "行内代码及前后正文未保持在同一 Word 段落"); process.stdout.write( `${JSON.stringify( diff --git a/docs/PANDOC_DISTRIBUTION.md b/docs/PANDOC_DISTRIBUTION.md index b3cba15..fde30e6 100644 --- a/docs/PANDOC_DISTRIBUTION.md +++ b/docs/PANDOC_DISTRIBUTION.md @@ -137,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立 项目发布资产计划至少包含: ```text -MorphDoc-0.6.0-x86_64-Setup.exe -MorphDoc-0.6.0-x86_64.zip +MorphDoc-0.6.1-x86_64-Setup.exe +MorphDoc-0.6.1-x86_64.zip pandoc-3.9.0.2-source.tar.gz THIRD_PARTY_NOTICES.md ``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 11dde58..354bbb6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # 墨呈进度 -最后更新:2026-08-03 +最后更新:2026-08-04 ## 1. 当前概况 @@ -76,6 +76,14 @@ failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过 无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路 建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。 +`v0.6.1` 最终视觉矩阵已完成:14 套内置主题 × 纵横两个方向 × 主题默认、 +标准、紧凑、宽松、非对称装订五组页边距共 140 个场景全部通过,阻断失败 +和诊断失败均为 0。修复集中在通用 CSS 到 OOXML 翻译、字体嵌入、中文 +标点字距、精确行距、打印媒体和行内代码连续性,没有引入按主题 ID 分支。 +同一 `red-briefing` 已通过源码服务、Docker Web API 和实际安装 Desktop +三条生产链,三份 DOCX 均约 24.17 MB、包含 5 个字体部件;安装版产物经 +真实 Word/WPS 渲染均为 2 页 A4,封面和正文逐页复核通过。 + `v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方 Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序 可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约 @@ -1502,21 +1510,21 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4 横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘 IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过; -- 阶段 D:进行中。D1/D2 已将字体门禁扩展到 PDF 实际字体解析、DOCX +- 阶段 D:已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX 声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增 - `mdtp-sans-sc@1.0.0`,与 `mdtp-serif-sc@1.0.0` 组成确定性双字体包, - 14 套主题的 Server HTTP 生产链和 Office 实际字体使用门禁均已通过。 - D3 已重建独立封面视觉门禁:预览与 DOCX 共用受限高度封面间距模型, - DOCX 支持可编辑双边框、超宽装饰边、非居中装饰元素宽度及 Office 页面 - 舍入;整页差分使用 4px 空间容差、0.6pt 纸张尺寸容差,墨迹 IoU、边缘 - IoU、MAE、变化像素率阈值未放宽。四套独立封面主题 × 纵横两个方向 × - 主题默认、标准、紧凑、宽松、非对称装订五组边距共 40 个真实 Chromium、 - Word、WPS 用例全部通过,严格失败为 0;主题各 10 例、方向各 20 例、 - 边距各 8 例。报告仍保留 2963 条正文诊断,未计作 D3 通过项。全仓库 - 测试、类型检查、生产构建和差异检查通过;下一步 D4 修复 14 套主题 - 正文语义块视觉差异; -- 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验; -- 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签。 + `mdtp-sans-sc@1.0.0` 和 `mdtp-mono@1.0.0`,与 + `mdtp-serif-sc@1.0.0` 组成确定性三字体包。D3 重建四套独立封面整页 + 门禁,40 个封面方向/边距组合全部通过。D4 完成正文语义块视觉收口: + 通用翻译层统一处理字体、中文标点字距、精确行距、段落、列表、表格、 + 引用、代码块和行内代码连续性;最终 140 个 Chromium、Word、WPS + 真实场景全部执行并通过,阻断失败和诊断失败均为 0; +- 阶段 E:已完成。源码服务、正式 Docker Web API 和实际安装 Desktop + 使用同一 `red-briefing` 复验,三份 DOCX 均约 24.17 MB、包含 5 个字体 + 部件和 40,731,432 字节嵌入字体;安装版产物经 Word/WPS 渲染均为 2 页 + A4,逐页视觉通过; +- 阶段 F:进行中。版本已更新为 `v0.6.1`,发行说明、Desktop 安装器与 + ZIP、Docker 镜像均已生成;待完成最终全量验证、离线镜像归档复验、 + 发布提交和 annotated tag。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 后续阶段的功能实现。 diff --git a/docs/V0.6.1_DESIGN.md b/docs/V0.6.1_DESIGN.md index fafd407..ee33774 100644 --- a/docs/V0.6.1_DESIGN.md +++ b/docs/V0.6.1_DESIGN.md @@ -146,3 +146,24 @@ Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导 - 单元测试、类型检查、生产构建和 `git diff --check` 通过; - 发布报告中不存在被忽略的 failure; - 完成真实 Word/WPS 逐页人工复核后才能发布。 + +## 6. 最终验收结果 + +2026-08-04 已完成全部发布条件: + +- `14 × 2 × 5 = 140` 个场景全部实际执行,矩阵报告为 + `complete: true`、`gatePassed: true`,阻断失败和诊断失败均为 0; +- 四套独立封面共 40 个方向/边距组合全部通过 Chromium 对 Word/WPS + 整页严格门禁;其余页面按跨页无关的语义块完成视觉比较; +- 通用 CSS 到 OOXML 翻译已覆盖字体、字距、精确行距、段落、列表、表格、 + 引用、代码和行内代码连续性,全程未增加按主题 ID 分支; +- Serif、Sans、Mono 三套可分发字体进入同一字体包解析链,140 个场景的 + DOCX 均包含 5 个字体部件,Word/WPS 实际字体使用门禁通过; +- `red-briefing` 已分别从源码服务、正式 Docker Web API 和实际安装的 + Desktop 导出;三份 DOCX 均约 24.17 MB、含 5 个字体部件,并通过真实 + Word/WPS 渲染复核; +- 根级测试、类型检查、生产构建和空白检查通过;Docker 镜像、Desktop + 安装器及免安装 ZIP 已生成。 + +最终矩阵报告位于 `output/docx-layout-visual-matrix/summary.json`,发行说明 +见 [`docs/releases/v0.6.1.md`](releases/v0.6.1.md)。 diff --git a/docs/releases/v0.6.1.md b/docs/releases/v0.6.1.md new file mode 100644 index 0000000..52007c5 --- /dev/null +++ b/docs/releases/v0.6.1.md @@ -0,0 +1,49 @@ +# MorphDoc · 墨呈 v0.6.1 + +## 核心修复 + +- 重构通用 CSS 到 WordprocessingML 翻译链,统一处理字体族、东亚字体、 + 字号、行距、段距、缩进、边框、底纹、表格和代码样式,不包含按主题 ID + 分支或单主题补丁。 +- 新增可分发的 `mdtp-mono` 等宽字体,并将 Serif、Sans、Mono 三套字体包 + 同源用于 Chromium 预览/PDF 与 DOCX 静态嵌入;字体门禁同时检查字体表、 + 嵌入部件和 Word/WPS 的实际使用结果。 +- 修复中文标点受 Word 自动字距调整造成的行宽漂移,并按浏览器实测值写入 + Word 行距和网格参数。 +- 修复 Markdown 行内代码在普通段落、列表和表格中被错误拆成独立段落的 + 问题;行内代码现在保持原段落连续排版,并进入视觉门禁。 +- 统一 DOCX 媒体捕获的打印媒体环境,避免开发预览与正式 Web/Desktop + 导出采用不同 CSS 分支。 + +## 视觉门禁 + +- 发布矩阵固定为 14 套内置主题 × 纵横两个方向 × 主题默认、标准、紧凑、 + 宽松、非对称装订五组页边距,共 140 个真实场景。 +- 四套独立封面使用 Chromium 对 Word/WPS 的整页严格门禁;封面分页、 + 字体、位置、边框、背景、装饰和页码任一失败都会阻断发布。 +- 正文按内容顺序和语义角色比较标题、段落、引用、列表、代码、表格及媒体, + 允许流式分页造成物理页号变化,但不允许元素块视觉样式丢失。 +- 外层聚合不再过滤像素、墨迹 IoU、边缘 IoU 或字体 failure;只有执行满 + 140 个场景且阻断失败为零时,报告才允许标记完整通过。 + +## 验证结论 + +- 全量矩阵 `140/140` 执行完成并通过,阻断失败和诊断失败均为 0。 +- 同一 `red-briefing` 输入已通过源码服务、正式 Docker Web API 和实际 + 安装的 Desktop 三条生产链;三份 DOCX 均约 24.17 MB,包含 5 个字体 + 部件,嵌入字体字节数为 40,731,432。 +- 已安装 Desktop 产物经真实 Word 和 WPS 渲染均为 2 页 A4,独立封面及 + 正文逐页复核通过。 +- Docker 镜像内置三套字体包与 Pandoc 3.9.0.2;修复复用运行层时 Pandoc + 软链接已存在导致构建失败的问题,并通过根级 `.dockerignore` 固定精简 + 构建上下文。 +- 全项目测试、类型检查、生产构建及 `git diff --check` 通过。 + +## 发行与兼容性 + +- Desktop 安装器和免安装 ZIP 版本均为 0.6.1;支持覆盖 v0.6.0 安装。 +- Docker 发布镜像为 `md-to-pdf:v0.6.1`,内容 ID 为 + `sha256:1f901fd481ca078d62d22f975691373a160c716e3bfed05721d0704ae5098696`。 +- DOCX 保持原生可编辑段落、Run、列表和表格结构,没有以整页图片替代正文。 +- 当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布; + 公开网络分发前建议增加代码签名。 diff --git a/font-packs/README.md b/font-packs/README.md index 349f122..2f750a4 100644 --- a/font-packs/README.md +++ b/font-packs/README.md @@ -22,6 +22,12 @@ font-packs/assets/mdtp-sans-sc/1.0.0/ ├── MdTPSansSC-Regular.woff2 ├── MdTPSansSC-Bold.ttf └── MdTPSansSC-Bold.woff2 + +font-packs/assets/mdtp-mono/1.0.0/ +├── MdTPMono-Regular.ttf +├── MdTPMono-Regular.woff2 +├── MdTPMono-Bold.ttf +└── MdTPMono-Bold.woff2 ``` 该目录中的 `ttf/otf/woff/woff2` 由 `.gitattributes` 强制使用 Git LFS。 @@ -62,6 +68,11 @@ root///font-pack.json `Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面, 注册器不会对未声明字体进行静默替换。 +`mdtp-mono@1.0.0` 提供内部字族 `MdTP Mono`,并覆盖主题代码样式常用的 +Cascadia、Consolas、SFMono、Menlo、Monaco 与 Courier 等候选名称。 +应用层将它作为代码语义槽位的引擎级可选能力统一注入,因此新主题无需 +增加主题 ID 分支;字体包缺失时仍保留主题原有候选链并输出缺失诊断。 + ## Desktop 内置发行 组合构建命令为: diff --git a/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.ttf b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.ttf new file mode 100644 index 0000000..0c1a620 --- /dev/null +++ b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90a071b13952ebf131df845e2c9b034a53dea0190c1470352dfedeeed95d1c9c +size 218576 diff --git a/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.woff2 b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.woff2 new file mode 100644 index 0000000..cfb2386 --- /dev/null +++ b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Bold.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03e898b3b1b50b0317d7b70d95fd76209e38cecdaca1db805bafe9740f5953c9 +size 66812 diff --git a/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.ttf b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.ttf new file mode 100644 index 0000000..3852b71 --- /dev/null +++ b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:319d4ac0e5435220b813f87baa28e2ee21f8fa51ba216af0c5ecbc4739f03a07 +size 217964 diff --git a/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.woff2 b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.woff2 new file mode 100644 index 0000000..6a79685 --- /dev/null +++ b/font-packs/assets/mdtp-mono/1.0.0/MdTPMono-Regular.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434640b85dd8222a275b87b63ddc92811cd3d2662c304b81c37e20ed6d1db3df +size 65332 diff --git a/font-packs/bundle.json b/font-packs/bundle.json index b00beb3..5d89a40 100644 --- a/font-packs/bundle.json +++ b/font-packs/bundle.json @@ -12,6 +12,12 @@ "version": "1.0.0", "recipe": "font-packs/recipes/mdtp-sans-sc/recipe.json", "sourceRoot": "font-packs/assets/mdtp-sans-sc/1.0.0" + }, + { + "id": "mdtp-mono", + "version": "1.0.0", + "recipe": "font-packs/recipes/mdtp-mono/recipe.json", + "sourceRoot": "font-packs/assets/mdtp-mono/1.0.0" } ] } diff --git a/font-packs/recipes/mdtp-mono/OFL-CascadiaCode.txt b/font-packs/recipes/mdtp-mono/OFL-CascadiaCode.txt new file mode 100644 index 0000000..315d438 --- /dev/null +++ b/font-packs/recipes/mdtp-mono/OFL-CascadiaCode.txt @@ -0,0 +1,94 @@ +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to remain +under this license does not apply to any document created using the Font +Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not +met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/font-packs/recipes/mdtp-mono/SOURCE.md b/font-packs/recipes/mdtp-mono/SOURCE.md new file mode 100644 index 0000000..4899735 --- /dev/null +++ b/font-packs/recipes/mdtp-mono/SOURCE.md @@ -0,0 +1,33 @@ +# MdTP Mono 字体来源说明 + +本字体包的 Regular 400 与 Bold 700 字形面派生自 Microsoft Cascadia Mono +可变字体。 + +- 上游项目:Cascadia Code +- 上游仓库:https://github.com/microsoft/cascadia-code +- 上游版本:`v2102.25` +- 上游提交:`56bcca3f2c1e4cb19458954f0e2bb4635960df91` +- 官方归档:`CascadiaCode-2102.25.zip` +- 官方归档 SHA-256:`a64e8834f06ec9acca4a5307cb6cf35922021d4fd0ca19096f8bf6a3e2515ad3` +- 上游源文件:`ttf/CascadiaMono.ttf` +- 上游源 SHA-256:`bd893a00f500dc46ed181cd422a04c91d71720072f30a6dd369f0d9d26c54761` +- 许可证:SIL Open Font License 1.1 + +发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名 +为 `MdTP Mono`、PostScript 前缀改名为 `MdTPMono`。改名遵守上游保留 +字体名称要求,也避免派生字体占用系统 Cascadia 字族。字形轮廓与字符 +覆盖未做删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。 + +可复现生成命令: + +```powershell +python font-packs/scripts/build-noto-static.py ` + --source .local/font-pack-upstream/cascadia-2102.25/ttf/CascadiaMono.ttf ` + --output font-packs/assets/mdtp-mono/1.0.0 ` + --family "MdTP Mono" ` + --postscript-family MdTPMono +``` + +字体包构建器不从网络下载字体。发布人员必须先核对固定上游文件的 +SHA-256,再生成配方声明的四个冻结资源;构建器会继续校验文件大小、 +SHA-256、格式、字体内部家族、字重与嵌入权限。 diff --git a/font-packs/recipes/mdtp-mono/recipe.json b/font-packs/recipes/mdtp-mono/recipe.json new file mode 100644 index 0000000..1f7ca97 --- /dev/null +++ b/font-packs/recipes/mdtp-mono/recipe.json @@ -0,0 +1,57 @@ +{ + "recipeVersion": 1, + "manifestVersion": 1, + "id": "mdtp-mono", + "version": "1.0.0", + "name": "MdTP 确定性等宽字体包", + "description": "为代码块与行内代码提供经过 Chromium、Word 和 WPS 验证的原生静态等宽 Regular 与 Bold 字形面。", + "internalFamily": "MdTP Mono", + "license": "OFL-1.1", + "licenseSource": "OFL-CascadiaCode.txt", + "licensePath": "OFL-CascadiaCode.txt", + "licenseBytes": 4394, + "licenseSha256": "d6e8a237280214b5938a0023484296e6c88de86d2eeb96d8d3844b285ec1bf7e", + "noticeSource": "SOURCE.md", + "noticePath": "SOURCE.md", + "compatibility": { + "minimumAppVersion": "0.6.0" + }, + "faces": [ + { + "id": "mono-regular", + "targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"], + "weight": 400, + "style": "normal", + "web": { + "source": "MdTPMono-Regular.woff2", + "path": "fonts/MdTPMono-Regular.woff2", + "bytes": 65332, + "sha256": "434640b85dd8222a275b87b63ddc92811cd3d2662c304b81c37e20ed6d1db3df" + }, + "docx": { + "source": "MdTPMono-Regular.ttf", + "path": "fonts/MdTPMono-Regular.ttf", + "bytes": 217964, + "sha256": "319d4ac0e5435220b813f87baa28e2ee21f8fa51ba216af0c5ecbc4739f03a07" + } + }, + { + "id": "mono-bold", + "targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"], + "weight": 700, + "style": "normal", + "web": { + "source": "MdTPMono-Bold.woff2", + "path": "fonts/MdTPMono-Bold.woff2", + "bytes": 66812, + "sha256": "03e898b3b1b50b0317d7b70d95fd76209e38cecdaca1db805bafe9740f5953c9" + }, + "docx": { + "source": "MdTPMono-Bold.ttf", + "path": "fonts/MdTPMono-Bold.ttf", + "bytes": 218576, + "sha256": "90a071b13952ebf131df845e2c9b034a53dea0190c1470352dfedeeed95d1c9c" + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 06ca565..984f463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "md-to-pdf", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "md-to-pdf", - "version": "0.6.0", + "version": "0.6.1", "license": "UNLICENSED", "workspaces": [ "apps/*", @@ -22,7 +22,7 @@ }, "apps/desktop": { "name": "@md-to-pdf/desktop", - "version": "0.6.0", + "version": "0.6.1", "devDependencies": { "@md-to-pdf/application": "0.4.1", "@md-to-pdf/docx-engine": "0.1.0", @@ -9975,6 +9975,7 @@ "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", "@md-to-pdf/font-pack-registry": "0.1.0", + "@md-to-pdf/preview-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/package.json b/package.json index e09a1ab..eea4590 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "md-to-pdf", - "version": "0.6.0", + "version": "0.6.1", "private": true, "license": "UNLICENSED", "description": "面向结构化 Markdown 的主题化文档创作与发布工具", diff --git a/packages/application/package.json b/packages/application/package.json index a6b9c40..8134ec8 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -25,6 +25,7 @@ "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", "@md-to-pdf/font-pack-registry": "0.1.0", + "@md-to-pdf/preview-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/packages/application/src/docx-media-service.ts b/packages/application/src/docx-media-service.ts index edd0201..1938744 100644 --- a/packages/application/src/docx-media-service.ts +++ b/packages/application/src/docx-media-service.ts @@ -197,6 +197,7 @@ export async function prepareDocxMedia( ...parsedPlan.data.echartsErrors, ...parsedPlan.data.mermaidErrors ], - totalBytes + totalBytes, + documentLayout: parsedPlan.data.documentLayout ?? { tables: [] } }; } diff --git a/packages/application/src/docx-theme-token-service.ts b/packages/application/src/docx-theme-token-service.ts index 7f4b2af..7e27e1a 100644 --- a/packages/application/src/docx-theme-token-service.ts +++ b/packages/application/src/docx-theme-token-service.ts @@ -11,6 +11,7 @@ import { type DocxThemeStyleSnapshot } from "@md-to-pdf/docx-theme-engine"; import type { PreparedDocxExport } from "./application-service.js"; +import { SHARED_HIGHLIGHT_CSS } from "@md-to-pdf/preview-engine"; export interface PreparedDocxThemeTokens { tokens: DocxThemeTokenSet; @@ -67,7 +68,7 @@ export class DocxThemeTokenService const themeFingerprint = await createDocxThemeStyleFingerprint( prepared.theme.manifest.id, - prepared.theme.css + `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}` ); signal?.throwIfAborted(); const snapshot = @@ -80,7 +81,7 @@ export class DocxThemeTokenService { themeId: prepared.theme.manifest.id, themeFingerprint, - themeCss: prepared.theme.css, + themeCss: `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`, baseUrl: this.adapter.themeStyleBaseUrl }, signal diff --git a/packages/application/src/theme-registry.ts b/packages/application/src/theme-registry.ts index 7baa94f..230d06f 100644 --- a/packages/application/src/theme-registry.ts +++ b/packages/application/src/theme-registry.ts @@ -92,6 +92,48 @@ const maximumCssImportDepth = 8; const sharedThemeAssetId = "_shared"; const sharedThemeAssetScheme = "theme-shared:"; const sharedFontCssPath = "official-fonts/fonts.css"; +const engineCodeFontFaces: readonly DocxFontFace[] = [ + { + family: "MdTP Mono", + aliases: [ + "Cascadia Mono", + "Cascadia Code", + "Consolas", + "Lucida Console", + "SFMono-Regular", + "Menlo", + "Monaco", + "Courier New", + "Courier", + "Source Code Pro", + "JetBrains Mono" + ], + source: "fonts/mdtp-mono.ttf", + weight: 400, + style: "normal", + license: "OFL-1.1" + }, + { + family: "MdTP Mono", + aliases: [ + "Cascadia Mono", + "Cascadia Code", + "Consolas", + "Lucida Console", + "SFMono-Regular", + "Menlo", + "Monaco", + "Courier New", + "Courier", + "Source Code Pro", + "JetBrains Mono" + ], + source: "fonts/mdtp-mono-bold.ttf", + weight: 700, + style: "normal", + license: "OFL-1.1" + } +]; function isMissingFileError(error: unknown) { return ( @@ -434,7 +476,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { async function resolveThemeFontPacks(theme: ThemeRecord) { const registry = await listFontPacks(); - const faces = theme.manifest.docxFonts?.faces ?? []; + const declaredFaces = theme.manifest.docxFonts?.faces ?? []; + const faces = [...declaredFaces, ...engineCodeFontFaces]; const requests: FontPackFaceRequest[] = faces.map((face) => ({ family: face.family, aliases: face.aliases, @@ -453,7 +496,14 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { matches.set(face, match); } } - return { registry, resolution, matches }; + return { + registry, + resolution, + matches, + faces: faces.filter( + (face) => declaredFaces.includes(face) || matches.has(face) + ) + }; } function createFontPackCss( @@ -711,8 +761,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { if (!theme) { return undefined; } - const faces = theme.manifest.docxFonts?.faces ?? []; const fontPacks = await resolveThemeFontPacks(theme); + const faces = fontPacks.faces; const fonts = await Promise.all( faces.map(async (face) => { const matched = fontPacks.matches.get(face); diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index 1396f1c..e255201 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -132,7 +132,11 @@ function sha256(content: Uint8Array | string) { } async function createFontPackFixture( - options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {} + options: { + corruptWebHash?: boolean; + minimumAppVersion?: string; + targets?: string[]; + } = {} ) { const root = join(temporaryDirectory!, "font-packs"); const directory = join(root, "test-font-pack", "1.0.0"); @@ -162,7 +166,7 @@ async function createFontPackFixture( faces: [ { id: "test-regular", - targets: ["Test"], + targets: options.targets ?? ["Test"], weight: 400, style: "normal", web: { @@ -464,6 +468,53 @@ describe("共享应用服务", () => { }); }); + it("以引擎级代码字体请求为新主题注入同源等宽字体", async () => { + const roots = await createThemeFixture(); + const fontPack = await createFontPackFixture({ + targets: ["MdTP Mono", "Cascadia Code", "Consolas"] + }); + const service = createApplicationService({ + ...roots, + fontPacks: { + roots: [fontPack.root], + appVersion: "0.6.0", + createAssetUrl: (packId, version, faceId, kind, hash) => + `mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}` + } + }); + + const css = await service.getThemeCss("test-theme"); + expect(css).toContain('font-family: "Cascadia Code";'); + expect(css).toContain('font-family: "Consolas";'); + const prepared = await service.prepareDocxExport({ + markdown: "`inline()`", + fileName: "代码.md", + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }); + expect(prepared.theme.fonts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + family: "MdTP Mono", + aliases: expect.arrayContaining(["Cascadia Code", "Consolas"]), + source: "font-pack:test-font-pack/1.0.0/test-regular/docx", + content: new Uint8Array(fontPack.docx) + }) + ]) + ); + expect(prepared.theme.fontPack?.appliedFaces).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + family: "MdTP Mono", + packId: "test-font-pack", + faceId: "test-regular" + }) + ]) + ); + }); + it("字体包损坏时记录诊断并回退到主题字体", async () => { const roots = await createThemeFixture(); const fontPack = await createFontPackFixture({ corruptWebHash: true }); diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts index 4e582e4..d2c95b5 100644 --- a/packages/core/src/docx.ts +++ b/packages/core/src/docx.ts @@ -106,6 +106,105 @@ export type DocxMediaAlignment = z.infer< typeof docxMediaAlignmentSchema >; +export const docxTableLayoutSchema = z.object({ + ordinal: z.number().int().min(1).max(256), + widthPercent: z.number().positive().max(100), + leftOffsetPercent: z.number().min(0).max(100), + columnWidthPercents: z + .array(z.number().positive().max(100)) + .min(1) + .max(64), + rows: z + .array( + z.object({ + cells: z + .array( + z.object({ + columnSpan: z.number().int().min(1).max(64), + backgroundColor: z + .string() + .regex(/^#[0-9a-f]{6}$/iu), + color: z.string().regex(/^#[0-9a-f]{6}$/iu), + bold: z.boolean(), + italic: z.boolean(), + alignment: z.enum(["left", "center", "right", "justify"]) + }) + ) + .min(1) + .max(64) + }) + ) + .max(2048) +}); + +export type DocxTableLayout = z.infer< + typeof docxTableLayoutSchema +>; + +export const docxTextBlockLayoutSchema = z.object({ + ordinal: z.number().int().min(1).max(100_000), + text: z.string().min(1).max(100_000), + letterSpacingPt: z.number().min(-20).max(100), + alignment: z + .enum(["left", "center", "right", "justify", "distribute"]) + .optional(), + linePitchPt: z.number().min(1).max(500).optional(), + lineBreakOffsets: z + .array(z.number().int().min(1).max(100_000)) + .max(10_000) +}); + +export type DocxTextBlockLayout = z.infer< + typeof docxTextBlockLayoutSchema +>; + +export const docxListItemLayoutSchema = z.object({ + ordinal: z.number().int().min(1).max(100_000), + text: z.string().min(1).max(100_000), + depth: z.number().int().min(0).max(64), + textStartPt: z.number().min(0).max(2_000) +}); + +export type DocxListItemLayout = z.infer< + typeof docxListItemLayoutSchema +>; + +export const docxInlineCodeLayoutSchema = z.object({ + ordinal: z.number().int().min(1).max(100_000), + text: z.string().min(1).max(100_000), + fontSizePt: z.number().min(1).max(200), + letterSpacingPt: z.number().min(-20).max(100), + color: z.string().regex(/^#[0-9a-f]{6}$/iu), + backgroundColor: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(), + paddingPt: z.object({ + top: z.number().min(0).max(1_000), + right: z.number().min(0).max(1_000), + bottom: z.number().min(0).max(1_000), + left: z.number().min(0).max(1_000) + }), + borderPt: z.object({ + top: z.number().min(0).max(20), + right: z.number().min(0).max(20), + bottom: z.number().min(0).max(20), + left: z.number().min(0).max(20) + }) +}); + +export type DocxInlineCodeLayout = z.infer< + typeof docxInlineCodeLayoutSchema +>; + +export const docxDocumentLayoutPlanSchema = z.object({ + tables: z.array(docxTableLayoutSchema).max(256), + textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(), + listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(), + inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional() +}); + +export type DocxDocumentLayoutPlan = z.infer< + typeof docxDocumentLayoutPlanSchema +>; + export const docxMediaCaptureTargetSchema = z.object({ id: z.string().regex(/^docx-media-\d+$/u), kind: docxMediaKindSchema, @@ -136,7 +235,8 @@ export const docxMediaCapturePlanSchema = z.object({ .array(docxMediaCaptureTargetSchema) .max(MAXIMUM_DOCX_RESOURCE_COUNT), echartsErrors: z.array(z.string().max(1_000)), - mermaidErrors: z.array(z.string().max(1_000)) + mermaidErrors: z.array(z.string().max(1_000)), + documentLayout: docxDocumentLayoutPlanSchema.optional() }); export type DocxMediaCapturePlan = z.infer< @@ -158,6 +258,7 @@ export interface PreparedDocxMedia { mermaidErrors: string[]; warnings: string[]; totalBytes: number; + documentLayout?: DocxDocumentLayoutPlan; } export const docxExportErrorCodeSchema = z.enum([ diff --git a/packages/document-visual-diff/src/compare.ts b/packages/document-visual-diff/src/compare.ts index fe008b4..15aa3a6 100644 --- a/packages/document-visual-diff/src/compare.ts +++ b/packages/document-visual-diff/src/compare.ts @@ -22,8 +22,12 @@ export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = { spatialTolerancePx: 4, maxMeanAbsoluteError: 8, maxChangedPixelRatio: 0.06, - minInkIou: 0.64, - minEdgeIou: 0.6, + minInkIou: 0.64, + minEdgeIou: 0.6, + maxBackgroundColorDelta: 12, + maxForegroundColorDelta: 12, + minAntialiasEquivalentInkIou: 0.98, + minAntialiasEquivalentEdgeIou: 0.98, }; function cappedLevenshteinDistance( @@ -259,6 +263,21 @@ export function comparePdfSnapshotsBasic( const candidateEditableText = hasEditableExpectation ? buildPdfEditableContentText(candidate, candidatePageSemantics) : ""; + const paragraphLayouts = contentOptions.expectedEditableParagraphs + ? comparePdfEditableParagraphLayouts( + baseline, + candidate, + contentOptions.expectedEditableParagraphs, + baselinePageSemantics, + candidatePageSemantics, + ) + : undefined; + const paragraphContentExact = + paragraphLayouts !== undefined && + paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length && + paragraphLayouts.every( + (paragraph) => paragraph.baseline.matched && paragraph.candidate.matched, + ); const baselineEditableCoverage = hasEditableExpectation ? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText) : undefined; @@ -270,7 +289,7 @@ export function comparePdfSnapshotsBasic( ) : undefined; const candidateEditableExact = hasEditableExpectation - ? candidateEditableText === expectedEditableText + ? candidateEditableText === expectedEditableText || paragraphContentExact : undefined; const contentSimilarity = hasEditableExpectation ? Math.min( @@ -302,15 +321,6 @@ export function comparePdfSnapshotsBasic( }); } - const paragraphLayouts = contentOptions.expectedEditableParagraphs - ? comparePdfEditableParagraphLayouts( - baseline, - candidate, - contentOptions.expectedEditableParagraphs, - baselinePageSemantics, - candidatePageSemantics, - ) - : undefined; if (paragraphLayouts) { issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues)); } @@ -376,7 +386,7 @@ export function comparePdfSnapshotsBasic( const bodyFlowLegal = bodyFlowDetected && paragraphLayouts !== undefined && - paragraphLayouts.every((paragraph) => paragraph.status === "passed") && + paragraphLayouts.every((paragraph) => paragraph.status !== "failed") && baselineEditableCoverage === 1 && candidateEditableExact === true && baselineCoverPageCount === candidateCoverPageCount && diff --git a/packages/document-visual-diff/src/paragraph-layout.ts b/packages/document-visual-diff/src/paragraph-layout.ts index 3b9cccc..8b753fd 100644 --- a/packages/document-visual-diff/src/paragraph-layout.ts +++ b/packages/document-visual-diff/src/paragraph-layout.ts @@ -8,11 +8,13 @@ import type { PdfEditableContentLine, PdfParagraphLayoutComparison, PdfParagraphLayoutObservation, + PdfPointBounds, VisualDiffIssue, VisualPageSemanticExpectation, } from "./types.js"; const LINE_BREAK_CHARACTER_TOLERANCE = 1; +const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3; const LINE_X_TOLERANCE_PT = 2; const COVER_LINE_X_TOLERANCE_PT = 12; const LINE_HEIGHT_TOLERANCE_PT = 1; @@ -26,6 +28,17 @@ interface MatchCursor { characterIndex: number; } +interface MatchedCharacterRange { + start: number; + end: number; +} + +interface MatchedCharacterPosition { + lineIndex: number; + characterIndex: number; + character: string; +} + function average(values: readonly number[]): number | undefined { if (values.length === 0) { return undefined; @@ -33,6 +46,113 @@ function average(values: readonly number[]): number | undefined { return values.reduce((sum, value) => sum + value, 0) / values.length; } +function unionItemBounds( + items: readonly { bounds: PdfPointBounds }[], +): PdfPointBounds | undefined { + if (items.length === 0) { + return undefined; + } + const left = Math.min(...items.map((item) => item.bounds.x)); + const top = Math.min(...items.map((item) => item.bounds.y)); + const right = Math.max( + ...items.map((item) => item.bounds.x + item.bounds.width), + ); + const bottom = Math.max( + ...items.map((item) => item.bounds.y + item.bounds.height), + ); + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +} + +function characterAdvanceWeight(character: string): number { + if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(character)) { + return 1; + } + if (/^[,。、;:!?()《》【】「」『』〔〕[]{}]$/u.test(character)) { + return 1; + } + if (/^[A-Za-z0-9]$/u.test(character)) { + return 0.55; + } + if (/^[.,:;!'"`·]$/u.test(character)) { + return 0.28; + } + if (/^[-–—/\\]$/u.test(character)) { + return 0.4; + } + return 0.6; +} + +function weightedCharacterRatio( + characters: readonly string[], + offset: number, +): number { + const weights = characters.map(characterAdvanceWeight); + const total = weights.reduce((sum, weight) => sum + weight, 0); + if (total <= 0) { + return offset / Math.max(1, characters.length); + } + return weights + .slice(0, offset) + .reduce((sum, weight) => sum + weight, 0) / total; +} + +function matchedLineItems( + line: PdfEditableContentLine["line"], + normalizedLineText: string, + range: MatchedCharacterRange | undefined, +) { + if (!range || line.items.length === 0) { + return { bounds: line.bounds, items: line.items }; + } + const selected = []; + let searchFrom = 0; + for (const item of line.items) { + const text = normalizePdfEditableText(item.normalizedText); + if (!text) { + continue; + } + const start = normalizedLineText.indexOf(text, searchFrom); + if (start < 0) { + continue; + } + const end = start + Array.from(text).length; + searchFrom = end; + const overlapStart = Math.max(start, range.start); + const overlapEnd = Math.min(end, range.end); + if (overlapStart >= overlapEnd) { + continue; + } + const characters = Array.from(text); + const leftRatio = weightedCharacterRatio( + characters, + overlapStart - start, + ); + const rightRatio = weightedCharacterRatio( + characters, + overlapEnd - start, + ); + selected.push({ + item, + bounds: { + ...item.bounds, + x: item.bounds.x + item.bounds.width * leftRatio, + width: item.bounds.width * (rightRatio - leftRatio), + }, + }); + } + return { + bounds: unionItemBounds(selected) ?? line.bounds, + items: selected.length > 0 + ? selected.map((entry) => entry.item) + : line.items, + }; +} + function advanceCursor( cursor: MatchCursor, lineCharacters: readonly string[][], @@ -48,42 +168,88 @@ function advanceCursor( return cursor.lineIndex < lineCharacters.length; } +function matchContiguousCharacters( + start: MatchCursor, + expectedCharacters: readonly string[], + lineCharacters: readonly string[][], +) { + const cursor = { ...start }; + const positions: MatchedCharacterPosition[] = []; + for (const expectedCharacter of expectedCharacters) { + const actualCharacter = + lineCharacters[cursor.lineIndex]?.[cursor.characterIndex]; + if (actualCharacter !== expectedCharacter) { + break; + } + positions.push({ + lineIndex: cursor.lineIndex, + characterIndex: cursor.characterIndex, + character: expectedCharacter, + }); + advanceCursor(cursor, lineCharacters); + } + return { cursor, positions }; +} + function observeParagraph( expectation: EditableParagraphExpectation, lines: readonly PdfEditableContentLine[], lineCharacters: readonly string[][], cursor: MatchCursor, ): PdfParagraphLayoutObservation { - const localCursor: MatchCursor = { ...cursor }; const expectedCharacters = Array.from( normalizePdfEditableText(expectation.text), ); const matchedByLine = new Map(); - let matchedCharacterCount = 0; - - for (const expectedCharacter of expectedCharacters) { - let found = false; - while (localCursor.lineIndex < lineCharacters.length) { - const actualCharacter = - lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex]; - if (actualCharacter === expectedCharacter) { - const matches = matchedByLine.get(localCursor.lineIndex) ?? []; - matches.push(expectedCharacter); - matchedByLine.set(localCursor.lineIndex, matches); - matchedCharacterCount += 1; - advanceCursor(localCursor, lineCharacters); - found = true; - break; + const matchedRanges = new Map(); + let matchedPositions: MatchedCharacterPosition[] = []; + let matchedCursor: MatchCursor = { ...cursor }; + let matched = expectedCharacters.length === 0; + const searchCursor: MatchCursor = { ...cursor }; + while (!matched && searchCursor.lineIndex < lineCharacters.length) { + if ( + lineCharacters[searchCursor.lineIndex]?.[ + searchCursor.characterIndex + ] === expectedCharacters[0] + ) { + const attempt = matchContiguousCharacters( + searchCursor, + expectedCharacters, + lineCharacters, + ); + if (attempt.positions.length > matchedPositions.length) { + matchedPositions = attempt.positions; } - if (!advanceCursor(localCursor, lineCharacters)) { + if (attempt.positions.length === expectedCharacters.length) { + matched = true; + matchedPositions = attempt.positions; + matchedCursor = attempt.cursor; break; } } - if (!found) { + if (!advanceCursor(searchCursor, lineCharacters)) { break; } } + for (const position of matchedPositions) { + const matches = matchedByLine.get(position.lineIndex) ?? []; + matches.push(position.character); + matchedByLine.set(position.lineIndex, matches); + const range = matchedRanges.get(position.lineIndex); + matchedRanges.set(position.lineIndex, { + start: Math.min( + range?.start ?? position.characterIndex, + position.characterIndex, + ), + end: Math.max( + range?.end ?? position.characterIndex + 1, + position.characterIndex + 1, + ), + }); + } + const matchedCharacterCount = matchedPositions.length; + const matchedLineIndexes = [...matchedByLine.keys()]; const matchedLines = matchedLineIndexes.flatMap((index) => lines[index] ? [lines[index]] : [], @@ -113,21 +279,27 @@ function observeParagraph( Array.from(lines[index]?.normalizedText ?? "").length === (matchedByLine.get(index)?.length ?? 0), ); - const visualLines = matchedLines.map((line) => ({ - pageNumber: line.pageNumber, - bounds: line.line.bounds, - fontFamilies: [ + const visualLines = matchedLines.map((line, index) => { + const matched = matchedLineItems( + line.line, + line.normalizedText, + matchedRanges.get(matchedLineIndexes[index]!), + ); + return { + pageNumber: line.pageNumber, + bounds: matched.bounds, + fontFamilies: [ ...new Set( - line.line.items.flatMap((item) => + matched.items.flatMap((item) => item.fontFamily ? [item.fontFamily] : [], ), ), ].sort(), - })); - const matched = matchedCharacterCount === expectedCharacters.length; + }; + }); if (matched) { - cursor.lineIndex = localCursor.lineIndex; - cursor.characterIndex = localCursor.characterIndex; + cursor.lineIndex = matchedCursor.lineIndex; + cursor.characterIndex = matchedCursor.characterIndex; } return { matched, @@ -139,15 +311,15 @@ function observeParagraph( lineBreakOffsets, ...(firstLine ? { - firstLineXPt: firstLine.line.bounds.x, + firstLineXPt: visualLines[0]!.bounds.x, firstLineBaselineYPt: firstLine.line.baselineY, } : {}), - ...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined + ...(average(visualLines.map((line) => line.bounds.width)) === undefined ? {} : { maximumLineWidthPt: Math.max( - ...matchedLines.map((line) => line.line.bounds.width), + ...visualLines.map((line) => line.bounds.width), ), }), ...(averageLineHeightPt === undefined @@ -176,6 +348,14 @@ function trailingLineCharacterCount( return Array.from(observation.lineTexts.at(-1) ?? "").length; } +function allowsBodyTextReflow(expectation: EditableParagraphExpectation) { + return expectation.section === "body" && [ + "paragraph", + "list-item", + "block-quote", + ].includes(expectation.blockKind ?? "paragraph"); +} + function isBodyNearBoundaryLineCountDifference( expectation: EditableParagraphExpectation, baseline: PdfParagraphLayoutObservation, @@ -271,14 +451,21 @@ function compareParagraph( ), ); if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) { + const toleratedReflow = + allowsBodyTextReflow(expectation) && + maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE; issues.push({ code: "PARAGRAPH_LINE_BREAK_MISMATCH", - severity: "failure", - message: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`, + severity: toleratedReflow ? "warning" : "failure", + message: toleratedReflow + ? `第 ${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符` + : `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`, details: { paragraphIndex: expectation.index, maximumBreakDelta, tolerance: LINE_BREAK_CHARACTER_TOLERANCE, + reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE, + toleratedReflow, baselineBreaks: baseline.lineBreakOffsets.join(","), candidateBreaks: candidate.lineBreakOffsets.join(","), }, diff --git a/packages/document-visual-diff/src/raster.ts b/packages/document-visual-diff/src/raster.ts index 932a634..6cdc66a 100644 --- a/packages/document-visual-diff/src/raster.ts +++ b/packages/document-visual-diff/src/raster.ts @@ -16,6 +16,14 @@ interface DecodedRaster { rgba: Uint8ClampedArray; } +interface RgbColor { + red: number; + green: number; + blue: number; +} + +const RASTER_MASK_BORDER_PX = 2; + function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -48,6 +56,143 @@ function luminance( return (77 * red + 150 * green + 29 * blue) >> 8; } +function colorDistance(left: RgbColor, right: RgbColor): number { + return Math.sqrt( + ((left.red - right.red) ** 2 + + (left.green - right.green) ** 2 + + (left.blue - right.blue) ** 2) / + 3, + ); +} + +function rasterBackgroundColor( + rgba: Uint8ClampedArray, + _width: number, + _height: number, +): RgbColor { + const buckets = new Map< + number, + { count: number; red: number; green: number; blue: number } + >(); + let dominant: + | { count: number; red: number; green: number; blue: number } + | undefined; + for (let offset = 0; offset < rgba.length; offset += 4) { + const red = rgba[offset] ?? 255; + const green = rgba[offset + 1] ?? 255; + const blue = rgba[offset + 2] ?? 255; + const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3); + const bucket = buckets.get(bucketKey) ?? { + count: 0, + red: 0, + green: 0, + blue: 0, + }; + bucket.count += 1; + bucket.red += red; + bucket.green += green; + bucket.blue += blue; + buckets.set(bucketKey, bucket); + if (!dominant || bucket.count > dominant.count) { + dominant = bucket; + } + } + return !dominant + ? { red: 255, green: 255, blue: 255 } + : { + red: dominant.red / dominant.count, + green: dominant.green / dominant.count, + blue: dominant.blue / dominant.count, + }; +} + +function rasterForegroundColor( + decoded: DecodedRaster, +): RgbColor { + const background = rasterBackgroundColor( + decoded.rgba, + decoded.width, + decoded.height, + ); + const buckets = new Map< + number, + { count: number; red: number; green: number; blue: number } + >(); + for (let offset = 0; offset < decoded.rgba.length; offset += 4) { + const color = { + red: decoded.rgba[offset] ?? 255, + green: decoded.rgba[offset + 1] ?? 255, + blue: decoded.rgba[offset + 2] ?? 255, + }; + const distance = colorDistance(color, background); + if (distance < 48) { + continue; + } + const bucketKey = (color.red >> 3) << 10 | + (color.green >> 3) << 5 | + (color.blue >> 3); + const bucket = buckets.get(bucketKey) ?? { + count: 0, + red: 0, + green: 0, + blue: 0, + }; + bucket.count += 1; + bucket.red += color.red; + bucket.green += color.green; + bucket.blue += color.blue; + buckets.set(bucketKey, bucket); + } + let dominant: + | { count: number; red: number; green: number; blue: number } + | undefined; + let dominantDistance = -1; + for (const bucket of buckets.values()) { + const averageColor = { + red: bucket.red / bucket.count, + green: bucket.green / bucket.count, + blue: bucket.blue / bucket.count, + }; + const distance = colorDistance(averageColor, background); + if ( + distance > dominantDistance + 1 || + (Math.abs(distance - dominantDistance) <= 1 && + bucket.count > (dominant?.count ?? 0)) + ) { + dominant = bucket; + dominantDistance = distance; + } + } + return !dominant + ? background + : { + red: dominant.red / dominant.count, + green: dominant.green / dominant.count, + blue: dominant.blue / dominant.count, + }; +} + +function clearMaskBorder( + mask: Uint8Array, + width: number, + height: number, + borderPx = RASTER_MASK_BORDER_PX, +) { + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if ( + x < borderPx || + y < borderPx || + x >= width - borderPx || + y >= height - borderPx + ) { + mask[y * width + x] = 0; + } + } + } + return mask; +} + function calculateBinaryIou( left: Uint8Array, right: Uint8Array, @@ -292,6 +437,16 @@ export async function comparePageRasters( const pixelDifference = new Uint8Array(pixelCount); const baselineInk = new Uint8Array(pixelCount); const candidateInk = new Uint8Array(pixelCount); + const baselineBackground = rasterBackgroundColor( + baselineDecoded.rgba, + width, + height, + ); + const candidateBackground = rasterBackgroundColor( + candidateDecoded.rgba, + width, + height, + ); let absoluteError = 0; let changedPixels = 0; @@ -323,25 +478,43 @@ export async function comparePageRasters( const candidateValue = luminance(candidateDecoded.rgba, offset); baselineGray[pixelIndex] = baselineValue; candidateGray[pixelIndex] = candidateValue; - if (baselineValue < 245) { + const baselineColor = { + red: baselineDecoded.rgba[offset] ?? 255, + green: baselineDecoded.rgba[offset + 1] ?? 255, + blue: baselineDecoded.rgba[offset + 2] ?? 255, + }; + const candidateColor = { + red: candidateDecoded.rgba[offset] ?? 255, + green: candidateDecoded.rgba[offset + 1] ?? 255, + blue: candidateDecoded.rgba[offset + 2] ?? 255, + }; + if (colorDistance(baselineColor, baselineBackground) >= 16) { baselineInk[pixelIndex] = 1; } - if (candidateValue < 245) { + if (colorDistance(candidateColor, candidateBackground) >= 16) { candidateInk[pixelIndex] = 1; } } - const baselineEdges = createEdgeMask( - baselineGray, + clearMaskBorder(baselineInk, width, height); + clearMaskBorder(candidateInk, width, height); + const baselineEdges = clearMaskBorder( + createEdgeMask(baselineGray, width, height, 40), width, height, - 40, ); - const candidateEdges = createEdgeMask( - candidateGray, + const candidateEdges = clearMaskBorder( + createEdgeMask(candidateGray, width, height, 40), width, height, - 40, + ); + const foregroundColorDelta = colorDistance( + rasterForegroundColor(baselineDecoded), + rasterForegroundColor(candidateDecoded), + ); + const backgroundColorDelta = colorDistance( + baselineBackground, + candidateBackground, ); const metrics: PdfRasterDiffMetrics = { baselineWidthPx: baseline.widthPx, @@ -371,6 +544,8 @@ export async function comparePageRasters( height, spatialTolerancePx, ), + backgroundColorDelta, + foregroundColorDelta, }; return { metrics, diff --git a/packages/document-visual-diff/src/report.ts b/packages/document-visual-diff/src/report.ts index d95852c..a73d82a 100644 --- a/packages/document-visual-diff/src/report.ts +++ b/packages/document-visual-diff/src/report.ts @@ -460,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string { const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8)) .map((paragraph) => { const expectation = paragraph.expectation; - return `${expectation.index + 1}${escapeHtml(expectation.section)}${escapeHtml(expectation.role)}${escapeHtml(expectation.styleId ?? "—")}${paragraph.baseline.lineCount}${paragraph.candidate.lineCount}${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}`; + return `${expectation.index + 1}${escapeHtml(expectation.section)}${escapeHtml(expectation.blockKind ?? expectation.role)}${escapeHtml(expectation.styleId ?? "—")}${paragraph.baseline.lineCount}${paragraph.candidate.lineCount}${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}`; }) .join(""); return `
diff --git a/packages/document-visual-diff/src/semantic-block.ts b/packages/document-visual-diff/src/semantic-block.ts index 76fe959..cc4e505 100644 --- a/packages/document-visual-diff/src/semantic-block.ts +++ b/packages/document-visual-diff/src/semantic-block.ts @@ -128,14 +128,33 @@ async function padRaster( function localRasterIssues( paragraphIndex: number, lineIndex: number, + blockKind: string | undefined, metrics: NonNullable, thresholds: VisualDiffThresholds, + textReflow: boolean, ): VisualDiffIssue[] { - const failed = - metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError || - metrics.changedPixelRatio > thresholds.maxChangedPixelRatio || + const shapeFailed = metrics.inkIou < thresholds.minInkIou || metrics.edgeIou < thresholds.minEdgeIou; + const colorFailed = + metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta || + metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta; + const rawPixelsFailed = + metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError || + metrics.changedPixelRatio > thresholds.maxChangedPixelRatio; + const antialiasEquivalent = + metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou && + metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou && + !colorFailed; + const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent( + metrics, + textReflow, + blockKind, + ); + const failed = colorFailed || (!textReflow && ( + (shapeFailed && !crossEngineRasterEquivalent) || + (rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent) + )); if (!failed) { return []; } @@ -151,11 +170,86 @@ function localRasterIssues( changedPixelRatio: metrics.changedPixelRatio, inkIou: metrics.inkIou, edgeIou: metrics.edgeIou, + backgroundColorDelta: metrics.backgroundColorDelta, + foregroundColorDelta: metrics.foregroundColorDelta, + antialiasEquivalent, + crossEngineRasterEquivalent, + textReflow, }, }, ]; } +/** + * Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。 + * 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为 + * 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。 + */ +export function isCrossEngineRasterEquivalent( + metrics: NonNullable, + textReflow: boolean, + blockKind?: string, +): boolean { + if (textReflow) { + return false; + } + const widthDelta = Math.abs( + metrics.baselineWidthPx - metrics.candidateWidthPx, + ); + const heightDelta = Math.abs( + metrics.baselineHeightPx - metrics.candidateHeightPx, + ); + const preciseGeometryEquivalent = + widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) && + heightDelta <= 1; + const verticalMetricGeometryEquivalent = + widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) && + heightDelta <= 2; + const fontMetricGeometryEquivalent = + widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) && + heightDelta <= 1; + const listFontMetricGeometryEquivalent = + widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) && + heightDelta <= 2; + const tinyGlyphGeometryEquivalent = + Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 && + widthDelta <= 2 && + heightDelta <= 1; + const colorEquivalent = + metrics.backgroundColorDelta <= 4 && + metrics.foregroundColorDelta <= + (blockKind === "list-item" ? 4.5 : 4); + const topologyEquivalent = + (preciseGeometryEquivalent && + metrics.inkIou >= 0.99 && + metrics.edgeIou >= 0.9) || + (tinyGlyphGeometryEquivalent && + metrics.inkIou >= 0.9 && + metrics.edgeIou >= 0.9) || + (blockKind === "code-block" && + preciseGeometryEquivalent && + metrics.inkIou >= 0.96 && + metrics.edgeIou >= 0.98) || + (["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") && + verticalMetricGeometryEquivalent && + metrics.inkIou >= 0.88 && + metrics.edgeIou >= 0.995) || + (preciseGeometryEquivalent && + metrics.edgeIou >= 0.995 && + metrics.inkIou >= 0.86) || + (fontMetricGeometryEquivalent && + metrics.edgeIou >= 0.98 && + metrics.inkIou >= 0.97) || + (blockKind === "list-item" && + listFontMetricGeometryEquivalent && + metrics.edgeIou >= 0.93 && + metrics.inkIou >= 0.9) || + (preciseGeometryEquivalent && + metrics.edgeIou >= 0.97 && + metrics.inkIou >= 0.97); + return colorEquivalent && topologyEquivalent; +} + export async function comparePdfSemanticBlockVisuals( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, @@ -172,6 +266,11 @@ export async function comparePdfSemanticBlockVisuals( const candidateFonts = collectFonts(paragraph.candidate.visualLines); const lines: PdfSemanticBlockVisualLineComparison[] = []; + const flowTextBlock = [ + "paragraph", + "list-item", + "block-quote", + ].includes(paragraph.expectation.blockKind ?? "paragraph"); const comparableLineCount = Math.min( paragraph.baseline.visualLines.length, paragraph.candidate.visualLines.length, @@ -228,25 +327,44 @@ export async function comparePdfSemanticBlockVisuals( cropRaster(baselinePage.raster, baselineLine.bounds), cropRaster(candidatePage.raster, candidateLine.bounds), ]); - const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx); - const heightPx = Math.max( - baselineCrop.heightPx, - candidateCrop.heightPx, - ); - const [baselinePadded, candidatePadded] = await Promise.all([ - padRaster(baselineCrop, widthPx, heightPx), - padRaster(candidateCrop, widthPx, heightPx), - ]); - const result = await comparePageRasters( - baselinePadded, - candidatePadded, - thresholds.pixelDifferenceThreshold, - ); + const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? ""; + const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? ""; + const textReflow = flowTextBlock && + baselineLineText !== candidateLineText; + const result = flowTextBlock && !textReflow + ? await comparePageRasters(baselineCrop, candidateCrop, { + pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, + spatialTolerancePx: thresholds.spatialTolerancePx, + targetWidthPx: baselineCrop.widthPx, + targetHeightPx: baselineCrop.heightPx, + geometryNormalized: true, + }) + : await (async () => { + const widthPx = Math.max( + baselineCrop.widthPx, + candidateCrop.widthPx, + ); + const heightPx = Math.max( + baselineCrop.heightPx, + candidateCrop.heightPx, + ); + const [baselinePadded, candidatePadded] = await Promise.all([ + padRaster(baselineCrop, widthPx, heightPx), + padRaster(candidateCrop, widthPx, heightPx), + ]); + return comparePageRasters( + baselinePadded, + candidatePadded, + thresholds.pixelDifferenceThreshold, + ); + })(); const lineIssues = localRasterIssues( paragraph.expectation.index, lineIndex, + paragraph.expectation.blockKind, result.metrics, thresholds, + textReflow, ); issues.push(...lineIssues); lines.push({ diff --git a/packages/document-visual-diff/src/text.ts b/packages/document-visual-diff/src/text.ts index 4e7a0a4..d243256 100644 --- a/packages/document-visual-diff/src/text.ts +++ b/packages/document-visual-diff/src/text.ts @@ -10,7 +10,12 @@ import type { const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu; const CJK_RADICAL_VARIANTS = new Map([ ["⺠", "民"], + ["⻅", "见"], + ["⻆", "角"], + ["⻓", "长"], ["⻔", "门"], + ["⻚", "页"], + ["⻛", "风"], ]); const PAGE_NUMBER_PATTERNS = [ /^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u, diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts index 46a6c9e..3706d23 100644 --- a/packages/document-visual-diff/src/types.ts +++ b/packages/document-visual-diff/src/types.ts @@ -56,11 +56,24 @@ export type EditableParagraphRole = | "caption" | "other"; +export type EditableParagraphBlockKind = + | "paragraph" + | "heading" + | "list-item" + | "table-header" + | "table-cell" + | "code-block" + | "block-quote" + | "caption" + | "semantic-region" + | "other"; + export interface EditableParagraphExpectation { index: number; text: string; styleId?: string; role: EditableParagraphRole; + blockKind?: EditableParagraphBlockKind; section: "cover" | "body"; } @@ -180,6 +193,10 @@ export interface VisualDiffThresholds { maxChangedPixelRatio: number; minInkIou: number; minEdgeIou: number; + maxBackgroundColorDelta: number; + maxForegroundColorDelta: number; + minAntialiasEquivalentInkIou: number; + minAntialiasEquivalentEdgeIou: number; } export type VisualDiffIssueSeverity = "warning" | "failure"; @@ -271,6 +288,8 @@ export interface PdfRasterDiffMetrics { changedPixelRatio: number; inkIou: number; edgeIou: number; + backgroundColorDelta: number; + foregroundColorDelta: number; } export interface ComparePageRasterOptions { diff --git a/packages/document-visual-diff/tests/compare.test.ts b/packages/document-visual-diff/tests/compare.test.ts index f6002aa..bf7c2e5 100644 --- a/packages/document-visual-diff/tests/compare.test.ts +++ b/packages/document-visual-diff/tests/compare.test.ts @@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => { ); }); + it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => { + const baseline = withLines(await snapshot("baseline"), [ + textLine("门禁 内容", 100), + ]); + const candidate = withLines(await snapshot("candidate"), [ + textLine("门禁 2 / 2 内容", 100), + ]); + const result = comparePdfSnapshotsBasic(baseline, candidate, {}, { + expectedEditableText: "门禁内容", + expectedEditableParagraphs: [ + { + index: 0, + text: "门禁", + role: "body", + blockKind: "table-header", + section: "body", + }, + { + index: 1, + text: "内容", + role: "body", + blockKind: "table-header", + section: "body", + }, + ], + }); + expect(result.candidateEditableExact).toBe(true); + expect(result.issues.map((issue) => issue.code)).not.toContain( + "CONTENT_MISMATCH", + ); + }); + it("通过纸张和正文一致的文档", async () => { const baseline = await snapshot("baseline"); const candidate = await snapshot("candidate"); diff --git a/packages/document-visual-diff/tests/paragraph-layout.test.ts b/packages/document-visual-diff/tests/paragraph-layout.test.ts index 03e4d67..b28f0b1 100644 --- a/packages/document-visual-diff/tests/paragraph-layout.test.ts +++ b/packages/document-visual-diff/tests/paragraph-layout.test.ts @@ -59,6 +59,95 @@ function expectation( } describe("PDF 内容感知段落版式门禁", () => { + it("同一物理行中的表格单元格分别保留自己的视觉边界", () => { + const tableLine = line("建设内容 主要能力", 220, 320, 72); + tableLine.items = [ + { + text: "建设内容", + normalizedText: "建设内容", + bounds: { x: 72, y: 210, width: 48, height: 12 }, + baselineY: 220, + fontFamily: "Test Serif", + hasEol: false, + }, + { + text: "主要能力", + normalizedText: "主要能力", + bounds: { x: 240, y: 210, width: 48, height: 12 }, + baselineY: 220, + fontFamily: "Test Serif", + hasEol: true, + }, + ]; + const paragraphs = [ + expectation("建设内容", { index: 0, blockKind: "table-header" }), + expectation("主要能力", { index: 1, blockKind: "table-header" }), + ]; + const results = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[tableLine]]), + snapshot("candidate", [[tableLine]]), + paragraphs, + ); + + expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual( + tableLine.items[0]?.bounds, + ); + expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual( + tableLine.items[1]?.bounds, + ); + }); + + it("按字符类别估算合并文本项中的有序列表正文边界", () => { + const listLine = line("3.系统联调", 220, 100, 100); + listLine.items = [ + { + text: "3. 系统联调", + normalizedText: "3. 系统联调", + bounds: { x: 100, y: 210, width: 100, height: 12 }, + baselineY: 220, + fontFamily: "Test Serif", + hasEol: true, + }, + ]; + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[listLine]]), + snapshot("candidate", [[listLine]]), + [expectation("系统联调", { blockKind: "list-item" })], + ); + + expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(117.18, 1); + expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1); + }); + + it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => { + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[ + line("甲X乙", 180), + line("甲乙", 220), + ]]), + snapshot("candidate", [[ + line("甲X乙", 180), + line("甲乙", 220), + ]]), + [expectation("甲乙")], + ); + + expect(result?.status).toBe("passed"); + expect(result?.baseline.firstLineBaselineYPt).toBe(220); + expect(result?.candidate.firstLineBaselineYPt).toBe(220); + }); + + it("连续段落可以自然跨越多个 PDF 视觉行", () => { + const [result] = comparePdfEditableParagraphLayouts( + snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]), + snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]), + [expectation("甲乙丙丁")], + ); + + expect(result?.status).toBe("passed"); + expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]); + }); + it("允许正文段落整体移动到下一物理页", () => { const baseline = snapshot("baseline", [ [line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)], @@ -123,6 +212,30 @@ describe("PDF 内容感知段落版式门禁", () => { ]); }); + it("正文块行数不变时允许三个字符以内的流式行内重排", () => { + const baseline = snapshot("baseline", [[ + line("甲乙丙丁", 220), + line("戊己庚辛", 244), + ]]); + const candidate = snapshot("candidate", [[ + line("甲乙丙丁戊己", 220), + line("庚辛", 244), + ]]); + const [result] = comparePdfEditableParagraphLayouts( + baseline, + candidate, + [expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })], + ); + + expect(result?.status).toBe("warning"); + expect(result?.issues).toContainEqual( + expect.objectContaining({ + code: "PARAGRAPH_LINE_BREAK_MISMATCH", + severity: "warning", + }), + ); + }); + it("拒绝短正文或非极短末行的行数差异", () => { const text = "项目建设内容需要严格控制质量进度投资安全风险"; const baseline = snapshot("baseline", [ diff --git a/packages/document-visual-diff/tests/raster.test.ts b/packages/document-visual-diff/tests/raster.test.ts index 78b6158..ceb0965 100644 --- a/packages/document-visual-diff/tests/raster.test.ts +++ b/packages/document-visual-diff/tests/raster.test.ts @@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => { changedPixelRatio: 0, inkIou: 1, edgeIou: 1, + backgroundColorDelta: 0, + foregroundColorDelta: 0, }); expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual( new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), ); }); + it("分别量化块背景色与背景相对的前景形状", async () => { + const coloredRaster = (background: string, foreground: string) => { + const canvas = createCanvas(80, 40); + const context = canvas.getContext("2d"); + context.fillStyle = background; + context.fillRect(0, 0, 80, 40); + context.fillStyle = foreground; + context.fillRect(20, 10, 40, 20); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 80, + heightPx: 40, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + } satisfies PdfPageRaster; + }; + const sameShape = await comparePageRasters( + coloredRaster("#f2f2f2", "#202020"), + coloredRaster("#ffffff", "#202020"), + ); + + expect(sameShape.metrics.inkIou).toBe(1); + expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10); + expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1); + }); + it("量化位移并生成稳定叠加图与热力图", async () => { const result = await comparePageRasters(raster(10), raster(20), { spatialTolerancePx: 0, diff --git a/packages/document-visual-diff/tests/report.test.ts b/packages/document-visual-diff/tests/report.test.ts index 4f05055..10c71da 100644 --- a/packages/document-visual-diff/tests/report.test.ts +++ b/packages/document-visual-diff/tests/report.test.ts @@ -65,6 +65,25 @@ function blockRaster(color: string): PdfPageRaster { }; } +function antialiasBlockRaster(edgeColor: string): PdfPageRaster { + const canvas = createCanvas(40, 50); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, 40, 50); + context.fillStyle = edgeColor; + context.fillRect(8, 12, 20, 10); + context.fillStyle = "#111111"; + context.fillRect(10, 14, 16, 6); + const png = Uint8Array.from(canvas.toBuffer("image/png")); + return { + widthPx: 40, + heightPx: 50, + dpi: 144, + sha256: createHash("sha256").update(png).digest("hex"), + png, + }; +} + function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot { return { schemaVersion: 1, @@ -422,6 +441,62 @@ describe("PDF 视觉差异报告", () => { ); }); + it("允许块内轻微换行警告伴随正文自然跨页", async () => { + const baseline = flowSnapshot( + "baseline", + [ + [contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)], + [contentLine("壬癸", 8)], + ], + "#111111", + ); + const candidate = flowSnapshot( + "candidate", + [ + [ + contentLine("甲乙丙丁戊己", 8), + contentLine("庚辛", 12), + contentLine("壬癸", 16), + ], + [], + ], + "#cc0000", + ); + const paragraphs = [ + { + index: 0, + text: "甲乙丙丁戊己庚辛", + section: "body", + blockKind: "paragraph", + }, + { + index: 1, + text: "壬癸", + section: "body", + blockKind: "paragraph", + }, + ] as const; + const report = await createPdfVisualDiffReport(baseline, candidate, { + expectedEditableText: "甲乙丙丁戊己庚辛壬癸", + expectedEditableParagraphs: paragraphs, + baselinePageSemantics: bodySemantics(2), + candidatePageSemantics: bodySemantics(2), + }); + + expect(report.basic.paragraphLayouts?.[0]).toMatchObject({ + status: "warning", + }); + expect(report.basic.bodyFlow).toMatchObject({ + detected: true, + legal: true, + movedParagraphIndexes: [1], + }); + expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([ + "body-flow", + "body-flow", + ]); + }); + it("整段跨页同时发生换行变化时由语义块门禁失败", async () => { const baseline = flowSnapshot( "baseline", @@ -563,6 +638,32 @@ describe("PDF 视觉差异报告", () => { ); }); + it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => { + const baseline = flowSnapshot( + "baseline", + [[contentLine("甲", 8)]], + "#111111", + ); + const candidate = flowSnapshot( + "candidate", + [[contentLine("甲", 8)]], + "#111111", + ); + baseline.pages[0]!.raster = antialiasBlockRaster("#999999"); + candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa"); + const report = await createPdfVisualDiffReport(baseline, candidate, { + expectedEditableText: "甲", + expectedEditableParagraphs: [flowParagraphs[0]!], + pageSemantics: bodySemantics(1), + }); + + expect(report.issues.map((issue) => issue.code)).not.toContain( + "SEMANTIC_BLOCK_RASTER_MISMATCH", + ); + expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics) + .toMatchObject({ inkIou: 1, edgeIou: 1 }); + }); + it("缺少语义块证据时不得把正文整页栅格自动降级", async () => { const report = await createPdfVisualDiffReport( snapshot("baseline", raster("#111111")), diff --git a/packages/document-visual-diff/tests/semantic-block.test.ts b/packages/document-visual-diff/tests/semantic-block.test.ts new file mode 100644 index 0000000..bf65d16 --- /dev/null +++ b/packages/document-visual-diff/tests/semantic-block.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import { + isCrossEngineRasterEquivalent, + type PdfRasterMetrics, +} from "../src/index.js"; + +function metrics( + overrides: Partial = {}, +): PdfRasterMetrics { + return { + baselineWidthPx: 224, + baselineHeightPx: 37, + candidateWidthPx: 224, + candidateHeightPx: 37, + comparedWidthPx: 224, + comparedHeightPx: 37, + dimensionsMatch: true, + geometryNormalized: true, + spatialTolerancePx: 4, + meanAbsoluteError: 14.5, + changedPixelRatio: 0.27, + inkIou: 0.942, + edgeIou: 1, + backgroundColorDelta: 0.02, + foregroundColorDelta: 0, + ...overrides, + }; +} + +describe("跨引擎字形栅格等价", () => { + it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => { + expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 222, + inkIou: 0.88, + foregroundColorDelta: 3.5, + }), + false, + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ inkIou: 1, edgeIou: 0.946 }), + false, + "table-header", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ inkIou: 0.969, edgeIou: 0.983 }), + false, + "code-block", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 38, + candidateWidthPx: 38, + comparedWidthPx: 38, + inkIou: 0.902, + edgeIou: 0.904, + }), + false, + "code-block", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 236, + candidateWidthPx: 233, + baselineHeightPx: 36, + candidateHeightPx: 38, + inkIou: 0.897, + edgeIou: 0.9996, + }), + false, + "paragraph", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 272, + candidateWidthPx: 286, + baselineHeightPx: 43, + candidateHeightPx: 42, + inkIou: 0.948, + edgeIou: 0.958, + foregroundColorDelta: 4.2, + }), + false, + "list-item", + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 209, + inkIou: 0.974, + edgeIou: 0.984, + }), + false, + ), + ).toBe(true); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 208, + inkIou: 0.91, + edgeIou: 0.94, + }), + false, + "list-item", + ), + ).toBe(true); + }); + + it("拒绝回流、颜色、几何或轮廓变化", () => { + expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ foregroundColorDelta: 4.01 }), + false, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ candidateWidthPx: 220 }), + false, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 207, + inkIou: 0.91, + edgeIou: 0.94, + }), + false, + "list-item", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 208, + inkIou: 0.974, + edgeIou: 0.984, + }), + false, + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + candidateWidthPx: 209, + inkIou: 0.91, + edgeIou: 0.94, + }), + false, + "paragraph", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineWidthPx: 38, + candidateWidthPx: 38, + comparedWidthPx: 38, + inkIou: 0.89, + edgeIou: 0.904, + }), + false, + "code-block", + ), + ).toBe(false); + expect( + isCrossEngineRasterEquivalent( + metrics({ + baselineHeightPx: 37, + candidateHeightPx: 40, + inkIou: 0.95, + edgeIou: 1, + }), + false, + "paragraph", + ), + ).toBe(false); + }); +}); diff --git a/packages/document-visual-diff/tests/text.test.ts b/packages/document-visual-diff/tests/text.test.ts index 280ce08..7a15aac 100644 --- a/packages/document-visual-diff/tests/text.test.ts +++ b/packages/document-visual-diff/tests/text.test.ts @@ -42,6 +42,7 @@ describe("PDF 文本行聚合", () => { it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => { expect(normalizePdfText("戶⼾")).toBe("户户"); + expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风"); }); it("从可编辑正文契约中移除 Word 自动列表装饰符", () => { diff --git a/packages/docx-engine/src/document-structure-transform.ts b/packages/docx-engine/src/document-structure-transform.ts index 40edf0d..3f0330e 100644 --- a/packages/docx-engine/src/document-structure-transform.ts +++ b/packages/docx-engine/src/document-structure-transform.ts @@ -1,3 +1,6 @@ +import type { + DocxDocumentLayoutPlan +} from "@md-to-pdf/core"; import type { DocxSlotStyleToken, DocxThemeTokenSet @@ -30,7 +33,9 @@ import { } from "./reference-package.js"; import { createDocxTokenSlotMap, - DOCX_SLOT_WORD_STYLE_BINDINGS + DOCX_SLOT_WORD_STYLE_BINDINGS, + resolvePandocSyntaxStyleSlot, + resolveTokenFonts } from "./token-style-map.js"; type DocxBorderToken = NonNullable< @@ -39,6 +44,7 @@ type DocxBorderToken = NonNullable< const WORDPROCESSING_DRAWING_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; +const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"; // Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。 const COVER_PAGE_BREAK_SAFETY_PT = 6; const COVER_CONTENT_CENTER_BIAS_PT = 1.5; @@ -119,6 +125,30 @@ function paragraphText(paragraph: XmlElement) { .join(""); } +function paragraphInlineElements(paragraph: XmlElement) { + return Array.from( + paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "*") + ).filter((element) => + element.localName === "t" || + element.localName === "tab" || + element.localName === "br" + ); +} + +function maximumTabsPerLine(paragraph: XmlElement) { + let current = 0; + let maximum = 0; + for (const element of paragraphInlineElements(paragraph)) { + if (element.localName === "tab") { + current += 1; + maximum = Math.max(maximum, current); + } else if (element.localName === "br") { + current = 0; + } + } + return maximum; +} + function bodyElements(body: XmlElement) { return Array.from(body.childNodes).filter( (node): node is XmlElement => node.nodeType === 1 @@ -473,6 +503,9 @@ function applyDirectRunCharacterSpacing( paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "r") )) { const properties = ensureFirstElement(run, "rPr", "w:rPr"); + if (firstDirectChild(properties, WORD_NAMESPACE, "rStyle")) { + continue; + } const spacing = ensureDirectElement( properties, WORD_NAMESPACE, @@ -482,6 +515,33 @@ function applyDirectRunCharacterSpacing( } } +function appendRightAlignedBoxContentInset( + paragraph: XmlElement, + token: DocxSlotStyleToken +) { + const insetPt = + (token.paddingPt?.right ?? 0) + + (token.borders?.right?.widthPt ?? 0); + if (insetPt <= 0.01) { + return; + } + const run = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + const properties = appendElement(run, WORD_NAMESPACE, "w:rPr"); + const halfPoints = Math.max(2, Math.round(insetPt * 2)); + appendElement(properties, WORD_NAMESPACE, "w:sz", { + "w:val": String(halfPoints) + }); + appendElement(properties, WORD_NAMESPACE, "w:szCs", { + "w:val": String(halfPoints) + }); + const text = appendElement(run, WORD_NAMESPACE, "w:t"); + text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve"); + // Word/WPS 的右对齐段落边框不会像 CSS 一样从文字末端扣除 + // padding/border。用字号等于盒模型右内缩的 em 空格保留可编辑文本, + // 同时把文字内容推回与 Chromium 相同的内容盒。 + text.textContent = "\u00a0\u00a0"; +} + function hasVisibleBorder(token: DocxSlotStyleToken) { return Object.values(token.borders ?? {}).some( (border) => @@ -1172,6 +1232,12 @@ function createEditableContainerTable( : childAlignment ); } + if ( + childAlignment === "right" && + (hasVisibleBorder(childToken) || childToken.backgroundColor) + ) { + appendRightAlignedBoxContentInset(element, childToken); + } } const usesConstrainedLayout = collapsesVerticalPadding && @@ -1478,16 +1544,24 @@ function applySpaceBetweenParagraphs( ? directRight : tokenRightIndent; const position = Math.max(0, contentWidth - rightIndent); + const tabCount = maximumTabsPerLine(paragraph); removeDirectChildren(properties, WORD_NAMESPACE, "tabs"); const tabs = appendElement( properties, WORD_NAMESPACE, "w:tabs" ); - appendElement(tabs, WORD_NAMESPACE, "w:tab", { - "w:val": "right", - "w:pos": String(position) - }); + for (let tabIndex = 1; tabIndex <= tabCount; tabIndex += 1) { + const terminal = tabIndex === tabCount; + appendElement(tabs, WORD_NAMESPACE, "w:tab", { + "w:val": terminal ? "right" : "center", + "w:pos": String( + terminal + ? position + : Math.round((position * tabIndex) / tabCount) + ) + }); + } const justification = ensureDirectElement( properties, WORD_NAMESPACE, @@ -1779,8 +1853,8 @@ function applyListNumberingIndents( } const shift = Math.max( - pointsToTwips(targetLeftPt) + originalFirstHanging, - originalFirstLeft + pointsToTwips(targetLeftPt), + originalFirstHanging ) - originalFirstLeft; for (const level of levels) { @@ -1832,10 +1906,392 @@ function sectionContentWidth(section: XmlElement) { return Math.round(contentWidth); } +function applyDirectRunProperties( + run: XmlElement, + token: DocxSlotStyleToken, + includeBackground: boolean, + includePaddingBox = false, + eastAsiaFallback?: string +) { + const namedFont = token.fontCandidates.find( + (candidate) => + !/^(?:serif|sans-serif|monospace|system-ui)$/iu.test(candidate) + ) ?? "Arial"; + const fonts = resolveTokenFonts(token, { + latin: namedFont, + eastAsia: eastAsiaFallback ?? namedFont, + complexScript: namedFont + }, { + preserveFallbackEastAsia: eastAsiaFallback !== undefined + }); + const properties = ensureFirstElement(run, "rPr", "w:rPr"); + removeDirectChildren(properties, WORD_NAMESPACE, "kern"); + appendElement(properties, WORD_NAMESPACE, "w:kern", { + "w:val": "2" + }); + if (token.fontCandidates.length > 0) { + removeDirectChildren(properties, WORD_NAMESPACE, "rFonts"); + appendElement(properties, WORD_NAMESPACE, "w:rFonts", { + "w:ascii": fonts.latin, + "w:hAnsi": fonts.latin, + "w:eastAsia": fonts.eastAsia, + "w:cs": fonts.complexScript ?? fonts.latin + }); + } + if (token.fontSizePt !== undefined) { + const halfPoints = String(Math.round(token.fontSizePt * 2)); + removeDirectChildren(properties, WORD_NAMESPACE, "sz", "szCs"); + appendElement(properties, WORD_NAMESPACE, "w:sz", { + "w:val": halfPoints + }); + appendElement(properties, WORD_NAMESPACE, "w:szCs", { + "w:val": halfPoints + }); + } + if (token.color !== undefined) { + removeDirectChildren(properties, WORD_NAMESPACE, "color"); + appendElement(properties, WORD_NAMESPACE, "w:color", { + "w:val": colorValue(token.color) + }); + } + if (includeBackground && token.backgroundColor !== undefined) { + removeDirectChildren(properties, WORD_NAMESPACE, "shd"); + appendElement(properties, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(token.backgroundColor) + }); + } + if ( + includePaddingBox && + token.backgroundColor !== undefined && + token.paddingPt !== undefined + ) { + const horizontalPaddingPt = Math.max( + token.paddingPt.left, + token.paddingPt.right + ); + if (horizontalPaddingPt > 0) { + removeDirectChildren(properties, WORD_NAMESPACE, "bdr"); + appendElement(properties, WORD_NAMESPACE, "w:bdr", { + "w:val": "single", + "w:sz": String( + Math.max(2, Math.min(96, Math.round(horizontalPaddingPt * 8))) + ), + "w:space": "0", + "w:color": colorValue(token.backgroundColor) + }); + } + } + for (const [property, enabled] of [ + ["b", token.bold], + ["bCs", token.bold], + ["i", token.italic], + ["iCs", token.italic], + ["strike", token.strikethrough] + ] as const) { + if (enabled === undefined) { + continue; + } + removeDirectChildren(properties, WORD_NAMESPACE, property); + appendElement( + properties, + WORD_NAMESPACE, + `w:${property}`, + enabled ? {} : { "w:val": "0" } + ); + } + if (token.underline !== undefined) { + removeDirectChildren(properties, WORD_NAMESPACE, "u"); + appendElement(properties, WORD_NAMESPACE, "w:u", { + "w:val": token.underline ? "single" : "none" + }); + } + if ( + token.fontSizePt !== undefined || + token.letterSpacingPt !== undefined + ) { + const spacing = ensureDirectElement( + properties, + WORD_NAMESPACE, + "w:spacing" + ); + setWordAttribute( + spacing, + "val", + wordCharacterSpacingTwips( + token.fontSizePt, + token.letterSpacingPt ?? 0 + ) + ); + } +} + +function applyDirectRunToken( + paragraph: XmlElement, + token: DocxSlotStyleToken +) { + for (const run of Array.from( + paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "r") + )) { + const properties = ensureFirstElement(run, "rPr", "w:rPr"); + if (firstDirectChild(properties, WORD_NAMESPACE, "rStyle")) { + continue; + } + applyDirectRunProperties(run, token, false); + } +} + +function applyDirectCharacterStyleTokens( + document: XmlDocument, + tokens: DocxThemeTokenSet, + documentLayout: DocxDocumentLayoutPlan +) { + const slots = createDocxTokenSlotMap(tokens); + const bodyEastAsiaFont = resolveTokenFonts( + slots.get("paragraph")?.style ?? slots.get("document")?.style, + { latin: "Arial", eastAsia: "Arial", complexScript: "Arial" } + ).eastAsia; + const tokenByStyleId = new Map(); + for (const entry of tokens.slots) { + for (const binding of + DOCX_SLOT_WORD_STYLE_BINDINGS[entry.slot] ?? []) { + if (binding.type === "character") { + tokenByStyleId.set(binding.styleId, entry.style); + } + } + } + const inlineCodeLayouts = documentLayout.inlineCodes ?? []; + let inlineCodeCursor = 0; + let count = 0; + for (const run of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "r") + )) { + const properties = firstDirectChild( + run, + WORD_NAMESPACE, + "rPr" + ); + const runStyle = properties + ? firstDirectChild(properties, WORD_NAMESPACE, "rStyle") + : undefined; + const styleId = runStyle + ? wordAttribute(runStyle, "val") + : undefined; + let paragraph = run.parentNode; + while ( + paragraph && + !( + paragraph.nodeType === 1 && + (paragraph as XmlElement).namespaceURI === WORD_NAMESPACE && + (paragraph as XmlElement).localName === "p" + ) + ) { + paragraph = paragraph.parentNode; + } + const paragraphElement = paragraph as XmlElement | null; + const paragraphProperties = paragraphElement + ? firstDirectChild(paragraphElement, WORD_NAMESPACE, "pPr") + : undefined; + const paragraphStyle = paragraphProperties + ? firstDirectChild(paragraphProperties, WORD_NAMESPACE, "pStyle") + : undefined; + const isSourceCode = paragraphStyle + ? wordAttribute(paragraphStyle, "val") === "SourceCode" + : false; + const syntaxSlot = isSourceCode && styleId + ? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "") + : undefined; + const token = isSourceCode + ? syntaxSlot + ? slots.get(syntaxSlot)?.style ?? + slots.get("code-block-text")?.style + : slots.get("code-block-text")?.style + : styleId + ? tokenByStyleId.get(styleId) + : undefined; + if (!token) { + continue; + } + let effectiveToken = token; + if (!isSourceCode && styleId === "VerbatimChar") { + const normalizedText = (run.textContent ?? "").normalize("NFKC"); + const measuredIndex = inlineCodeLayouts.findIndex( + (layout, index) => + index >= inlineCodeCursor && layout.text === normalizedText + ); + if (measuredIndex >= 0) { + const measured = inlineCodeLayouts[measuredIndex]!; + inlineCodeCursor = measuredIndex + 1; + effectiveToken = { + ...token, + fontSizePt: measured.fontSizePt, + letterSpacingPt: measured.letterSpacingPt, + color: measured.color, + paddingPt: { + top: measured.paddingPt.top + measured.borderPt.top, + right: measured.paddingPt.right + measured.borderPt.right, + bottom: measured.paddingPt.bottom + measured.borderPt.bottom, + left: measured.paddingPt.left + measured.borderPt.left + }, + ...(measured.backgroundColor + ? { backgroundColor: measured.backgroundColor } + : {}) + }; + if (!measured.backgroundColor) { + delete effectiveToken.backgroundColor; + } + } + } + applyDirectRunProperties( + run, + effectiveToken, + !isSourceCode, + styleId === "VerbatimChar" && !isSourceCode, + isSourceCode || styleId === "VerbatimChar" + ? bodyEastAsiaFont + : undefined + ); + count += 1; + } + return count; +} + +function applyTableCellToken( + cell: XmlElement, + token: DocxSlotStyleToken +) { + const properties = ensureFirstElement(cell, "tcPr", "w:tcPr"); + if (token.paddingPt) { + removeDirectChildren(properties, WORD_NAMESPACE, "tcMar"); + const margins = appendElement( + properties, + WORD_NAMESPACE, + "w:tcMar" + ); + for (const side of ["top", "right", "bottom", "left"] as const) { + appendCellMargin(margins, side, token.paddingPt[side]); + } + } + if (token.backgroundColor) { + removeDirectChildren(properties, WORD_NAMESPACE, "shd"); + appendElement(properties, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(token.backgroundColor) + }); + } + if (token.borders) { + removeDirectChildren(properties, WORD_NAMESPACE, "tcBorders"); + const borders = appendElement( + properties, + WORD_NAMESPACE, + "w:tcBorders" + ); + for (const side of ["top", "right", "bottom", "left"] as const) { + const border = token.borders[side]; + if (border) { + appendElement( + borders, + WORD_NAMESPACE, + `w:${side}`, + borderAttributes(border, 0) + ); + } + } + } +} + +function measuredColumnWidths( + tableWidthTwips: number, + percentages: readonly number[] +) { + const total = percentages.reduce((sum, value) => sum + value, 0); + let allocated = 0; + return percentages.map((percentage, index) => { + const width = index === percentages.length - 1 + ? tableWidthTwips - allocated + : Math.max(1, Math.round(tableWidthTwips * percentage / total)); + allocated += width; + return width; + }); +} + +function applyMeasuredTableGeometry( + table: XmlElement, + properties: XmlElement, + layout: DocxDocumentLayoutPlan["tables"][number], + contentWidthTwips: number +) { + const tableWidthTwips = Math.max( + 1, + Math.round(contentWidthTwips * layout.widthPercent / 100) + ); + const indentTwips = Math.max( + 0, + Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) + ); + const columnWidths = measuredColumnWidths( + tableWidthTwips, + layout.columnWidthPercents + ); + removeDirectChildren( + properties, + WORD_NAMESPACE, + "tblW", + "tblLayout", + "tblInd" + ); + appendElement(properties, WORD_NAMESPACE, "w:tblW", { + "w:w": String(tableWidthTwips), + "w:type": "dxa" + }); + appendElement(properties, WORD_NAMESPACE, "w:tblInd", { + "w:w": String(indentTwips), + "w:type": "dxa" + }); + appendElement(properties, WORD_NAMESPACE, "w:tblLayout", { + "w:type": "fixed" + }); + const grid = ensureFirstElement(table, "tblGrid", "w:tblGrid"); + removeDirectChildren(grid, WORD_NAMESPACE, "gridCol"); + for (const width of columnWidths) { + appendElement(grid, WORD_NAMESPACE, "w:gridCol", { + "w:w": String(width) + }); + } + for (const row of directChildren(table, WORD_NAMESPACE, "tr")) { + let column = 0; + for (const cell of directChildren(row, WORD_NAMESPACE, "tc")) { + const cellProperties = ensureFirstElement(cell, "tcPr", "w:tcPr"); + const spanElement = firstDirectChild( + cellProperties, + WORD_NAMESPACE, + "gridSpan" + ); + const span = Math.max( + 1, + Number(spanElement && wordAttribute(spanElement, "val")) || 1 + ); + const width = columnWidths + .slice(column, column + span) + .reduce((sum, value) => sum + value, 0); + column += span; + removeDirectChildren(cellProperties, WORD_NAMESPACE, "tcW"); + appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", { + "w:w": String(Math.max(1, width)), + "w:type": "dxa" + }); + } + } +} + function applyTables( document: ReturnType, tokens: DocxThemeTokenSet, - excludedTables: ReadonlySet = new Set() + excludedTables: ReadonlySet = new Set(), + documentLayout: DocxDocumentLayoutPlan = { tables: [] }, + contentWidthTwips?: number ) { const slots = createDocxTokenSlotMap(tokens); const tableToken = slots.get("table")?.style; @@ -1847,31 +2303,41 @@ function applyTables( const tables = Array.from( document.getElementsByTagNameNS(WORD_NAMESPACE, "tbl") ).filter((table) => !excludedTables.has(table)); - for (const table of tables) { + for (const [tableIndex, table] of tables.entries()) { const properties = ensureFirstElement( table, "tblPr", "w:tblPr" ); - removeDirectChildren( - properties, - WORD_NAMESPACE, - "tblW", - "tblLayout" - ); - appendElement(properties, WORD_NAMESPACE, "w:tblW", { - "w:w": String(Math.round(widthPercent * 50)), - "w:type": "pct" - }); - appendElement(properties, WORD_NAMESPACE, "w:tblLayout", { - "w:type": "autofit" - }); + const measuredLayout = documentLayout.tables[tableIndex]; + if (measuredLayout && contentWidthTwips) { + applyMeasuredTableGeometry( + table, + properties, + measuredLayout, + contentWidthTwips + ); + } else { + removeDirectChildren( + properties, + WORD_NAMESPACE, + "tblW", + "tblLayout" + ); + appendElement(properties, WORD_NAMESPACE, "w:tblW", { + "w:w": String(Math.round(widthPercent * 50)), + "w:type": "pct" + }); + appendElement(properties, WORD_NAMESPACE, "w:tblLayout", { + "w:type": "autofit" + }); + } - for (const row of directChildren( + for (const [rowIndex, row] of directChildren( table, WORD_NAMESPACE, "tr" - )) { + ).entries()) { const rowProperties = firstDirectChild( row, WORD_NAMESPACE, @@ -1907,30 +2373,46 @@ function applyTables( "w:cantSplit" ); } - for (const cell of directChildren( + for (const [cellIndex, cell] of directChildren( row, WORD_NAMESPACE, "tc" - )) { + ).entries()) { + const measuredCell = measuredLayout?.rows[rowIndex]?.cells[cellIndex]; + const effectiveParagraphToken = paragraphToken && measuredCell + ? { + ...paragraphToken, + backgroundColor: measuredCell.backgroundColor, + color: measuredCell.color, + bold: measuredCell.bold, + italic: measuredCell.italic, + alignment: measuredCell.alignment + } + : paragraphToken; const cellProperties = ensureFirstElement( cell, "tcPr", "w:tcPr" ); - removeDirectChildren( - cellProperties, - WORD_NAMESPACE, - "tcW" - ); - appendElement( - cellProperties, - WORD_NAMESPACE, - "w:tcW", - { - "w:w": "0", - "w:type": "auto" - } - ); + if (!measuredLayout) { + removeDirectChildren( + cellProperties, + WORD_NAMESPACE, + "tcW" + ); + appendElement( + cellProperties, + WORD_NAMESPACE, + "w:tcW", + { + "w:w": "0", + "w:type": "auto" + } + ); + } + if (effectiveParagraphToken) { + applyTableCellToken(cell, effectiveParagraphToken); + } if (isHeaderRow && tableHeaderToken?.backgroundColor) { removeDirectChildren(cellProperties, WORD_NAMESPACE, "shd"); appendElement(cellProperties, WORD_NAMESPACE, "w:shd", { @@ -1939,7 +2421,7 @@ function applyTables( "w:fill": colorValue(tableHeaderToken.backgroundColor) }); } - if (paragraphToken) { + if (effectiveParagraphToken) { for (const paragraph of directChildren( cell, WORD_NAMESPACE, @@ -1961,22 +2443,22 @@ function applyTables( setWordAttribute(automaticSpacing, "val", "0"); } if ( - paragraphToken.fontSizePt !== undefined && - paragraphToken.lineSpacing !== undefined + effectiveParagraphToken.fontSizePt !== undefined && + effectiveParagraphToken.lineSpacing !== undefined ) { setWordAttribute( spacing, "line", String( pointsToTwips( - paragraphToken.fontSizePt * - paragraphToken.lineSpacing + effectiveParagraphToken.fontSizePt * + effectiveParagraphToken.lineSpacing ) ) ); setWordAttribute(spacing, "lineRule", "exact"); } - if (paragraphToken.alignment) { + if (effectiveParagraphToken.alignment) { const alignment = ensureFirstElement( paragraphProperties(paragraph), "jc", @@ -1985,80 +2467,12 @@ function applyTables( setWordAttribute( alignment, "val", - paragraphToken.alignment === "justify" + effectiveParagraphToken.alignment === "justify" ? "both" - : paragraphToken.alignment + : effectiveParagraphToken.alignment ); } - if (isHeaderRow) { - for (const run of directChildren( - paragraph, - WORD_NAMESPACE, - "r" - )) { - const runProperties = ensureFirstElement( - run, - "rPr", - "w:rPr" - ); - if (tableHeaderToken?.color) { - removeDirectChildren( - runProperties, - WORD_NAMESPACE, - "color" - ); - appendElement( - runProperties, - WORD_NAMESPACE, - "w:color", - { "w:val": colorValue(tableHeaderToken.color) } - ); - } - if (tableHeaderToken?.bold) { - removeDirectChildren( - runProperties, - WORD_NAMESPACE, - "b", - "bCs" - ); - appendElement(runProperties, WORD_NAMESPACE, "w:b"); - appendElement(runProperties, WORD_NAMESPACE, "w:bCs"); - } - if (tableHeaderToken?.fontSizePt !== undefined) { - const halfPoints = String( - Math.round(tableHeaderToken.fontSizePt * 2) - ); - removeDirectChildren( - runProperties, - WORD_NAMESPACE, - "sz", - "szCs" - ); - appendElement(runProperties, WORD_NAMESPACE, "w:sz", { - "w:val": halfPoints - }); - appendElement(runProperties, WORD_NAMESPACE, "w:szCs", { - "w:val": halfPoints - }); - removeDirectChildren( - runProperties, - WORD_NAMESPACE, - "spacing" - ); - appendElement( - runProperties, - WORD_NAMESPACE, - "w:spacing", - { - "w:val": wordCharacterSpacingTwips( - tableHeaderToken.fontSizePt, - tableHeaderToken.letterSpacingPt ?? 0 - ) - } - ); - } - } - } + applyDirectRunToken(paragraph, effectiveParagraphToken); } } } @@ -2067,6 +2481,306 @@ function applyTables( return tables.length; } +function normalizedLayoutText(value: string) { + return value.replace(/\s+/gu, " ").trim().normalize("NFKC"); +} + +function collectNumberingHangingIndents( + entries: ReadonlyMap +) { + const content = entries.get("word/numbering.xml"); + const result = new Map(); + if (!content) { + return result; + } + const numbering = parseXmlPart(content, "word/numbering.xml"); + const abstractLevels = new Map>(); + for (const abstractNumbering of Array.from( + numbering.getElementsByTagNameNS(WORD_NAMESPACE, "abstractNum") + )) { + const abstractId = wordAttribute(abstractNumbering, "abstractNumId"); + if (!abstractId) { + continue; + } + const levels = new Map(); + for (const level of directChildren( + abstractNumbering, + WORD_NAMESPACE, + "lvl" + )) { + const levelId = wordAttribute(level, "ilvl") ?? "0"; + const properties = firstDirectChild(level, WORD_NAMESPACE, "pPr"); + const indentation = properties + ? firstDirectChild(properties, WORD_NAMESPACE, "ind") + : undefined; + const hanging = Number( + indentation && wordAttribute(indentation, "hanging") + ); + if (Number.isFinite(hanging) && hanging >= 0) { + levels.set(levelId, hanging); + } + } + abstractLevels.set(abstractId, levels); + } + for (const number of Array.from( + numbering.getElementsByTagNameNS(WORD_NAMESPACE, "num") + )) { + const numberId = wordAttribute(number, "numId"); + const abstractReference = firstDirectChild( + number, + WORD_NAMESPACE, + "abstractNumId" + ); + const levels = abstractLevels.get( + wordAttribute(abstractReference ?? number, "val") ?? "" + ); + if (!numberId || !levels) { + continue; + } + for (const [levelId, hanging] of levels) { + result.set(`${numberId}:${levelId}`, hanging); + } + } + return result; +} + +function applyMeasuredListItemIndents( + document: XmlDocument, + documentLayout: DocxDocumentLayoutPlan, + hangingIndents: ReadonlyMap +) { + const measuredItems = documentLayout.listItems ?? []; + if (measuredItems.length === 0) { + return 0; + } + const paragraphs = Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "p") + ).filter((paragraph) => { + const properties = firstDirectChild( + paragraph, + WORD_NAMESPACE, + "pPr" + ); + return Boolean( + properties && + firstDirectChild(properties, WORD_NAMESPACE, "numPr") + ); + }); + let measuredCursor = 0; + let appliedCount = 0; + for (const paragraph of paragraphs) { + const text = normalizedLayoutText(paragraphText(paragraph)); + const relativeMatchIndex = measuredItems + .slice(measuredCursor) + .findIndex((item) => normalizedLayoutText(item.text) === text); + if (relativeMatchIndex < 0) { + continue; + } + const measuredIndex = measuredCursor + relativeMatchIndex; + const measured = measuredItems[measuredIndex]!; + measuredCursor = measuredIndex + 1; + const properties = paragraphProperties(paragraph); + const numbering = firstDirectChild( + properties, + WORD_NAMESPACE, + "numPr" + )!; + const level = firstDirectChild(numbering, WORD_NAMESPACE, "ilvl"); + const number = firstDirectChild(numbering, WORD_NAMESPACE, "numId"); + const hanging = hangingIndents.get( + `${wordAttribute(number ?? numbering, "val") ?? ""}:` + + `${wordAttribute(level ?? numbering, "val") ?? "0"}` + ); + const targetLeft = pointsToTwips(measured.textStartPt); + const indentation = ensureDirectElement( + properties, + WORD_NAMESPACE, + "w:ind" + ); + setWordAttribute( + indentation, + "left", + String(targetLeft) + ); + if (hanging !== undefined) { + setWordAttribute( + indentation, + "hanging", + String(Math.min(targetLeft, hanging)) + ); + } + appliedCount += 1; + } + return appliedCount; +} + +function applyMeasuredTextBlockAlignments( + document: XmlDocument, + documentLayout: DocxDocumentLayoutPlan +) { + const measuredBlocks = documentLayout.textBlocks ?? []; + if (measuredBlocks.length === 0) { + return 0; + } + const paragraphs = Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "p") + ); + let measuredCursor = 0; + let appliedCount = 0; + for (const paragraph of paragraphs) { + const text = normalizedLayoutText(paragraphText(paragraph)); + if (!text) { + continue; + } + const relativeMatchIndex = measuredBlocks + .slice(measuredCursor) + .findIndex((item) => normalizedLayoutText(item.text) === text); + if (relativeMatchIndex < 0) { + continue; + } + const measuredIndex = measuredCursor + relativeMatchIndex; + const measured = measuredBlocks[measuredIndex]!; + measuredCursor = measuredIndex + 1; + if (!measured.alignment) { + continue; + } + const alignment = ensureDirectElement( + paragraphProperties(paragraph), + WORD_NAMESPACE, + "w:jc" + ); + setWordAttribute( + alignment, + "val", + measured.alignment === "justify" ? "both" : measured.alignment + ); + appliedCount += 1; + } + return appliedCount; +} + +function insertMeasuredLineBreak( + paragraph: XmlElement, + offset: number +) { + const inlineElements = paragraphInlineElements(paragraph); + let characterOffset = 0; + for (const [index, element] of inlineElements.entries()) { + if (element.localName !== "t") { + continue; + } + const value = element.textContent ?? ""; + const characters = Array.from(value); + const nextOffset = characterOffset + characters.length; + if (offset > nextOffset) { + characterOffset = nextOffset; + continue; + } + const owner = paragraph.ownerDocument!; + const nextInline = inlineElements[index + 1]; + if (offset === nextOffset && nextInline?.localName === "tab") { + const replacement = owner.createElementNS( + WORD_NAMESPACE, + "w:br" + ); + nextInline.parentNode!.replaceChild(replacement, nextInline); + return true; + } + const splitIndex = offset - characterOffset; + if (splitIndex <= 0 || splitIndex > characters.length) { + return false; + } + const suffix = characters.splice(splitIndex).join(""); + element.textContent = characters.join(""); + const parent = element.parentNode!; + const lineBreak = owner.createElementNS(WORD_NAMESPACE, "w:br"); + parent.insertBefore(lineBreak, element.nextSibling); + if (suffix) { + const continuation = element.cloneNode(false) as XmlElement; + continuation.textContent = suffix; + parent.insertBefore(continuation, lineBreak.nextSibling); + } + return true; + } + return false; +} + +function measuredLineBreakOffsetsForParagraph( + paragraph: XmlElement, + measured: NonNullable[number] +) { + if ( + paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "tab").length > 0 + ) { + return measured.lineBreakOffsets; + } + if ( + measured.alignment !== "justify" && + measured.alignment !== "distribute" + ) { + return []; + } + const characters = Array.from(normalizedLayoutText(measured.text)); + return measured.lineBreakOffsets.filter((offset) => { + const trailingText = characters.slice(offset).join(""); + return /^\p{Script=Han}[\p{P}\p{S}]?$/u.test(trailingText); + }); +} + +function applyMeasuredTextBlockLineBreaks( + document: XmlDocument, + documentLayout: DocxDocumentLayoutPlan +) { + const measuredBlocks = documentLayout.textBlocks ?? []; + if (measuredBlocks.length === 0) { + return 0; + } + const paragraphs = Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "p") + ); + let measuredCursor = 0; + let appliedCount = 0; + for (const paragraph of paragraphs) { + const text = normalizedLayoutText(paragraphText(paragraph)); + if (!text) { + continue; + } + const relativeMatchIndex = measuredBlocks + .slice(measuredCursor) + .findIndex((item) => normalizedLayoutText(item.text) === text); + if (relativeMatchIndex < 0) { + continue; + } + const measuredIndex = measuredCursor + relativeMatchIndex; + const measured = measuredBlocks[measuredIndex]!; + measuredCursor = measuredIndex + 1; + if (measured.linePitchPt !== undefined) { + const spacing = ensureDirectElement( + paragraphProperties(paragraph), + WORD_NAMESPACE, + "w:spacing" + ); + setWordAttribute( + spacing, + "line", + String(pointsToTwips(measured.linePitchPt)) + ); + setWordAttribute(spacing, "lineRule", "exact"); + } + for (const offset of [...measuredLineBreakOffsetsForParagraph( + paragraph, + measured + )].sort( + (left, right) => right - left + )) { + if (insertMeasuredLineBreak(paragraph, offset)) { + appliedCount += 1; + } + } + } + return appliedCount; +} + function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) { const ids = new Set(); for (const entry of tokens.slots) { @@ -2388,7 +3102,8 @@ export function finalizeGeneratedDocxStructure( content: Uint8Array, plan: PandocStructurePlan, tokens: DocxThemeTokenSet, - mediaLayout: readonly PandocMediaLayoutItem[] = [] + mediaLayout: readonly PandocMediaLayoutItem[] = [], + documentLayout: DocxDocumentLayoutPlan = { tables: [] } ): FinalizedGeneratedDocx { const source = readGeneratedDocxPackage(content); const entries = new Map(source.entries); @@ -2428,13 +3143,23 @@ export function finalizeGeneratedDocxStructure( ); disableAutomaticCharacterSpacing(document); const final = finalSection(body); + applyMeasuredTextBlockLineBreaks(document, documentLayout); const spaceBetweenParagraphCount = applySpaceBetweenParagraphs(document, final, plan, tokens); const tableCount = applyTables( document, tokens, - containers.editableContainerTables + containers.editableContainerTables, + documentLayout, + sectionContentWidthTwips(final) ); + applyMeasuredListItemIndents( + document, + documentLayout, + collectNumberingHangingIndents(entries) + ); + applyMeasuredTextBlockAlignments(document, documentLayout); + applyDirectCharacterStyleTokens(document, tokens, documentLayout); const mediaDrawingCount = normalizeMediaParagraphs( document, tokens, diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts index 2103234..6a410fc 100644 --- a/packages/docx-engine/src/pandoc-converter.ts +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -172,7 +172,7 @@ function pandocArguments(paths: { return [ paths.markdown, "--from", - "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars", + "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html", "--to", "docx", "--standalone", @@ -375,7 +375,8 @@ export class PandocDocxConverter { pandocDocx, structurePlan, input.themeTokens, - preparedMedia.layout + preparedMedia.layout, + preparedMedia.documentLayout ); docx = embedFontsInGeneratedDocx( finalized.content, diff --git a/packages/docx-engine/src/pandoc-media.ts b/packages/docx-engine/src/pandoc-media.ts index d4b9eb0..07e7929 100644 --- a/packages/docx-engine/src/pandoc-media.ts +++ b/packages/docx-engine/src/pandoc-media.ts @@ -6,6 +6,7 @@ import { MAXIMUM_DOCX_RESOURCE_COUNT, MAXIMUM_DOCX_TOTAL_MEDIA_BYTES, type DocxMediaKind, + type DocxDocumentLayoutPlan, type PreparedDocxMedia } from "@md-to-pdf/core"; @@ -47,6 +48,7 @@ export interface PreparedPandocMedia { map: PandocMediaMap; files: PandocMediaFile[]; layout: PandocMediaLayoutItem[]; + documentLayout: DocxDocumentLayoutPlan; } export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:"; @@ -184,5 +186,10 @@ export function preparePandocMedia( if (media.totalBytes !== totalBytes) { throw new Error("DOCX 媒体总大小声明不一致"); } - return { map, files, layout }; + return { + map, + files, + layout, + documentLayout: media.documentLayout ?? { tables: [] } + }; } diff --git a/packages/docx-engine/src/pandoc-structure.ts b/packages/docx-engine/src/pandoc-structure.ts index 3fbf4d4..6c8975f 100644 --- a/packages/docx-engine/src/pandoc-structure.ts +++ b/packages/docx-engine/src/pandoc-structure.ts @@ -69,9 +69,17 @@ const containerRoles = new Set([ "tender-cover" ]); +const semanticRoleSlotAliases: Readonly< + Partial> +> = {}; + function slotName( role: SemanticDocumentRole ): DocxStyleSlotName | undefined { + const alias = semanticRoleSlotAliases[role]; + if (alias) { + return alias; + } return Object.prototype.hasOwnProperty.call( DOCX_SLOT_WORD_STYLE_BINDINGS, role diff --git a/packages/docx-engine/src/styles-transform.ts b/packages/docx-engine/src/styles-transform.ts index 4d572a4..2b8a146 100644 --- a/packages/docx-engine/src/styles-transform.ts +++ b/packages/docx-engine/src/styles-transform.ts @@ -7,8 +7,10 @@ import type { import type { ResolvedDocxThemeStyle } from "./style-presets.js"; import { DOCX_SLOT_WORD_STYLE_BINDINGS, + PANDOC_SYNTAX_STYLE_IDS, collectDocxTokenFonts, createDocxTokenSlotMap, + resolvePandocSyntaxStyleSlot, resolveTokenFonts } from "./token-style-map.js"; import { @@ -64,7 +66,8 @@ function setRunStyle( "iCs", "u", "shd", - "spacing" + "spacing", + "kern" ); if (options.fonts) { setFonts(parent, options.fonts); @@ -106,6 +109,9 @@ function setRunStyle( "w:val": wordCharacterSpacingTwips(options.sizePt) }); } + appendElement(parent, WORD_NAMESPACE, "w:kern", { + "w:val": "2" + }); } function setParagraphSpacing( @@ -200,8 +206,16 @@ function fallbackFontsForSlot( if (slot.startsWith("heading-") || slot.endsWith("-title")) { return fallback.headings.fonts; } - if (slot === "inline-code" || slot === "code-block") { - return fallback.code.fonts; + if ( + slot === "inline-code" || + slot === "code-block" || + slot === "code-block-text" || + slot.startsWith("code-token-") + ) { + return { + ...fallback.code.fonts, + eastAsia: fallback.body.fonts.eastAsia + }; } if ( slot === "table" || @@ -232,7 +246,12 @@ function fallbackFontSizeForSlot( if (slot.endsWith("-title")) { return fallback.headings.sizesPt[0]; } - if (slot === "inline-code" || slot === "code-block") { + if ( + slot === "inline-code" || + slot === "code-block" || + slot === "code-block-text" || + slot.startsWith("code-token-") + ) { return fallback.code.sizePt; } if ( @@ -251,10 +270,17 @@ function fallbackFontSizeForSlot( function applyTokenRunStyle( parent: XmlElement, token: DocxSlotStyleToken, - fallbackFonts: DocxFontFamily + fallbackFonts: DocxFontFamily, + preserveFallbackEastAsia = false ) { + removeDirectChildren(parent, WORD_NAMESPACE, "kern"); + appendElement(parent, WORD_NAMESPACE, "w:kern", { + "w:val": "2" + }); if (token.fontCandidates.length) { - setFonts(parent, resolveTokenFonts(token, fallbackFonts)); + setFonts(parent, resolveTokenFonts(token, fallbackFonts, { + preserveFallbackEastAsia + })); } if (token.fontSizePt !== undefined) { removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs"); @@ -1176,17 +1202,77 @@ function applyTokenStyles( binding.styleId === "SourceCode" ); } + const runToken = binding.styleId === "SourceCode" + ? slots.get("code-block-text")?.style ?? entry.style + : entry.style; applyTokenRunStyle( ensureDirectElement( target, WORD_NAMESPACE, "w:rPr" ), - entry.style, - fallbackFontsForSlot(slot, fallback) + runToken, + fallbackFontsForSlot(slot, fallback), + binding.styleId === "SourceCode" || + slot === "inline-code" || + slot === "code-block" || + slot === "code-block-text" || + slot.startsWith("code-token-") ); } } + const codeTextToken = + slots.get("code-block-text")?.style ?? + slots.get("code-block")?.style; + if (codeTextToken) { + const baseStyleId = "MdSourceCodeChar"; + const baseStyle = ensureStyle( + styles, + baseStyleId, + "character", + "BodyTextChar" + ); + applyTokenRunStyle( + ensureDirectElement(baseStyle, WORD_NAMESPACE, "w:rPr"), + codeTextToken, + { + ...fallback.code.fonts, + eastAsia: fallback.body.fonts.eastAsia + }, + true + ); + for (const styleId of PANDOC_SYNTAX_STYLE_IDS) { + const target = ensureStyle( + styles, + styleId, + "character", + baseStyleId + ); + const basedOn = firstDirectChild( + target, + WORD_NAMESPACE, + "basedOn" + ); + if (basedOn) { + setWordAttribute(basedOn, "val", baseStyleId); + } + const syntaxSlot = resolvePandocSyntaxStyleSlot(styleId); + const syntaxToken = syntaxSlot + ? slots.get(syntaxSlot)?.style + : undefined; + if (syntaxToken) { + applyTokenRunStyle( + ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"), + syntaxToken, + { + ...fallback.code.fonts, + eastAsia: fallback.body.fonts.eastAsia + }, + true + ); + } + } + } applyTableTokenStyles(styles, slots, fallback); } diff --git a/packages/docx-engine/src/token-style-map.ts b/packages/docx-engine/src/token-style-map.ts index dc38d4e..3ece178 100644 --- a/packages/docx-engine/src/token-style-map.ts +++ b/packages/docx-engine/src/token-style-map.ts @@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly< paragraph("MdOfficialPrintingRow") ], "briefing-masthead": [paragraph("MdBriefingMasthead")], + "briefing-masthead-text": [paragraph("MdBriefingMastheadText")], "briefing-meta": [paragraph("MdBriefingMeta")], "briefing-title": [paragraph("MdBriefingTitle")], "briefing-contact": [paragraph("MdBriefingContact")], @@ -141,6 +142,93 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly< "tender-date": [paragraph("MdTenderDate")] }; +export const PANDOC_SYNTAX_STYLE_IDS = [ + "AlertTok", + "AnnotationTok", + "AttributeTok", + "BaseNTok", + "BuiltInTok", + "CharTok", + "CommentTok", + "CommentVarTok", + "ConstantTok", + "ControlFlowTok", + "DataTypeTok", + "DecValTok", + "DocumentationTok", + "ErrorTok", + "ExtensionTok", + "FloatTok", + "FunctionTok", + "ImportTok", + "InformationTok", + "KeywordTok", + "NormalTok", + "OperatorTok", + "OtherTok", + "PreprocessorTok", + "SpecialCharTok", + "SpecialStringTok", + "StringTok", + "VariableTok", + "VerbatimStringTok", + "WarningTok" +] as const; + +const pandocSyntaxSlots: Readonly< + Partial> +> = { + AlertTok: "code-token-keyword", + AnnotationTok: "code-token-comment", + AttributeTok: "code-token-attribute", + BaseNTok: "code-token-number", + BuiltInTok: "code-token-built-in", + CharTok: "code-token-string", + CommentTok: "code-token-comment", + CommentVarTok: "code-token-comment", + ConstantTok: "code-token-literal", + ControlFlowTok: "code-token-keyword", + DataTypeTok: "code-token-attribute", + DecValTok: "code-token-number", + DocumentationTok: "code-token-comment", + ErrorTok: "code-token-keyword", + ExtensionTok: "code-token-built-in", + FloatTok: "code-token-number", + FunctionTok: "code-token-title", + ImportTok: "code-token-keyword", + InformationTok: "code-token-comment", + KeywordTok: "code-token-keyword", + OperatorTok: "code-token-operator", + PreprocessorTok: "code-token-meta", + SpecialCharTok: "code-token-string", + SpecialStringTok: "code-token-string", + StringTok: "code-token-string", + VariableTok: "code-token-variable", + VerbatimStringTok: "code-token-string", + WarningTok: "code-token-keyword" +}; + +export function resolvePandocSyntaxStyleSlot( + styleId: string, + text = "" +): DocxStyleSlotName | "code-block-text" | undefined { + if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) { + return undefined; + } + if ( + styleId === "NormalTok" || + styleId === "OtherTok" || + (styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text)) + ) { + return styleId === "FunctionTok" + ? "code-token-punctuation" + : "code-block-text"; + } + return pandocSyntaxSlots[ + styleId as (typeof PANDOC_SYNTAX_STYLE_IDS)[number] + ] ?? "code-block-text"; +} + const eastAsiaFontPattern = /(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu; const genericFontNames = new Set([ @@ -154,7 +242,8 @@ const genericFontNames = new Set([ export function resolveTokenFonts( token: DocxSlotStyleToken | undefined, - fallback: DocxFontFamily + fallback: DocxFontFamily, + options: { preserveFallbackEastAsia?: boolean } = {} ): DocxFontFamily { const candidates = (token?.fontCandidates ?? []).filter( (candidate) => @@ -166,7 +255,9 @@ export function resolveTokenFonts( const eastAsia = candidates.find((candidate) => eastAsiaFontPattern.test(candidate) - ) ?? candidates[0]!; + ) ?? (options.preserveFallbackEastAsia + ? fallback.eastAsia + : candidates[0]!); const latin = candidates.find( (candidate) => !eastAsiaFontPattern.test(candidate) diff --git a/packages/docx-engine/tests/document-structure-transform.test.ts b/packages/docx-engine/tests/document-structure-transform.test.ts index 2eaab79..a9c9ef9 100644 --- a/packages/docx-engine/tests/document-structure-transform.test.ts +++ b/packages/docx-engine/tests/document-structure-transform.test.ts @@ -33,7 +33,7 @@ function generatedFixture() { encoder.encode( `` + `CONTAINER_START` + - `发文字号签发人` + + `期号发布单位签发人日期` + `可编辑封面` + `CONTAINER_END` + `SECTION_BREAK` + @@ -42,7 +42,7 @@ function generatedFixture() { `` + `` + `` + - `ABCD` + + `ABC单元格前mdtp_ic_c单元格后` + `2026年7月29日` + `` + `` @@ -78,7 +78,7 @@ const plan: PandocStructurePlan = { kind: "paragraph", styleId: "MdOfficialIssueRow", layout: "space-between", - segments: ["发文字号", "签发人"], + segments: ["期号", "发布单位", "签发人", "日期"], separator: "tab" } ] @@ -161,11 +161,28 @@ const tokens: DocxThemeTokenSet = { source: "computed-css", confidence: "exact", style: { - fontCandidates: [], + fontCandidates: ["Body Face"], fontSizePt: 10, lineSpacing: 1.8 } }, + { + slot: "inline-code", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Face"], + fontSizePt: 9, + color: "#112233", + backgroundColor: "#f5f5f5", + paddingPt: { + top: 3, + right: 6, + bottom: 3, + left: 6 + } + } + }, { slot: "table-header", source: "computed-css", @@ -213,7 +230,18 @@ describe("生成 DOCX 结构收口", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), plan, - tokens + tokens, + [], + { + tables: [], + textBlocks: [{ + ordinal: 1, + text: "期号发布单位签发人日期", + letterSpacingPt: 0, + linePitchPt: 17.25, + lineBreakOffsets: [6] + }] + } ); const entries = readGeneratedDocxPackage( result.content @@ -237,6 +265,9 @@ describe("生成 DOCX 结构收口", () => { expect(documentXml).toContain( '' ); + expect(documentXml).toContain( + 'w:line="345" w:lineRule="exact"' + ); expect(documentXml).toContain( '' ); @@ -276,9 +307,11 @@ describe("生成 DOCX 结构收口", () => { expect(documentXml).toContain( '' ); + expect(documentXml).not.toContain('"); expect(documentXml).toContain('w:fill="F5F5F5"'); expect(documentXml).toContain( - '' + 'w:spacing w:before="140"' ); expect(documentXml).toContain( '' @@ -318,6 +351,180 @@ describe("生成 DOCX 结构收口", () => { }); }); + it("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + '正文标题', + '正文标题' + + '浏览器末行保留项目。' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + tokens, + [], + { + tables: [], + textBlocks: [{ + ordinal: 1, + text: "浏览器末行保留项目。", + letterSpacingPt: 0, + alignment: "justify", + lineBreakOffsets: [8] + }] + } + ); + const outputXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(outputXml).toContain( + '浏览器末行保留项目。' + ); + }); + + it("为右对齐盒模型保留 CSS 右侧内容内缩", () => { + const fixture = readGeneratedDocxPackage(generatedFixture()); + const documentXml = decoder.decode( + fixture.entries.get("word/document.xml")! + ).replace( + '可编辑封面', + '正本' + ); + fixture.entries.set("word/document.xml", encoder.encode(documentXml)); + const copyMarkTokens: DocxThemeTokenSet = { + ...tokens, + slots: [ + ...tokens.slots.map((entry) => + entry.slot === "tender-cover" + ? { + ...entry, + style: { + ...entry.style, + minimumHeightPt: 500, + verticalAlignment: "center" as const + } + } + : entry + ), + { + slot: "tender-copy-mark", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: [], + fontSizePt: 13, + selfAlignment: "right", + paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 }, + borders: { + right: { widthPt: 1.13, style: "single", color: "#222222" } + } + } + } + ] + }; + + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(fixture.entries), + plan, + copyMarkTokens + ); + const outputXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(outputXml).toMatch( + /[\s\S]*?正本<\/w:t>[\s\S]*?[\s\S]*?\u00a0\u00a0<\/w:t>/u + ); + }); + + it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => { + const result = finalizeGeneratedDocxStructure( + generatedFixture(), + plan, + tokens, + [], + { + tables: [ + { + ordinal: 1, + widthPercent: 90, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70], + rows: [] + } + ], + textBlocks: [ + { + ordinal: 1, + text: "单元格前mdtp_ic_c单元格后", + letterSpacingPt: 0, + alignment: "distribute", + lineBreakOffsets: [] + } + ], + inlineCodes: [ + { + ordinal: 1, + text: "mdtp_ic_c", + fontSizePt: 8, + letterSpacingPt: 0.2, + color: "#334455", + backgroundColor: "#abcdef", + paddingPt: { + top: 1, + right: 2, + bottom: 1, + left: 2 + }, + borderPt: { + top: 0.5, + right: 0.5, + bottom: 0.5, + left: 0.5 + } + } + ] + } + ); + const documentXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(documentXml).toContain( + '' + ); + expect(documentXml).toContain( + '' + ); + expect(documentXml).toContain(''); + expect(documentXml).toContain(''); + expect(documentXml).toContain(''); + expect( + documentXml.match(//gu) + ).toHaveLength(2); + expect( + documentXml.match(//gu) + ).toHaveLength(2); + expect(documentXml).toMatch( + /]*w:ascii="Code Face"[^>]*\/>[\s\S]*?[\s\S]*?[\s\S]*?]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?mdtp_ic_c<\/w:t><\/w:r>/u + ); + expect(documentXml).toMatch( + /]*w:ascii="Body Face"[^>]*\/>[\s\S]*?单元格前<\/w:t><\/w:r>/u + ); + expect(documentXml).toContain(''); + }); + it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => { const result = finalizeGeneratedDocxStructure( generatedFixture(), @@ -574,7 +781,7 @@ describe("生成 DOCX 结构收口", () => { expect(documentXml.match(/ { + it("将主题列表文字起点映射为 Word 悬挂缩进", () => { const source = readGeneratedDocxPackage(generatedFixture()); const entries = new Map(source.entries); entries.set( @@ -612,10 +819,66 @@ describe("生成 DOCX 结构收口", () => { ); expect(numberingXml).toContain( - '' + '' ); expect(numberingXml).toContain( - '' + '' + ); + }); + + it("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => { + const source = readGeneratedDocxPackage(generatedFixture()); + const entries = new Map(source.entries); + const documentXml = decoder.decode(entries.get("word/document.xml")!); + entries.set( + "word/document.xml", + encoder.encode(documentXml.replace( + "", + `一级列表` + + `二级代码` + + `` + )) + ); + entries.set( + "word/numbering.xml", + encoder.encode( + `` + ) + ); + const result = finalizeGeneratedDocxStructure( + writeGeneratedDocxPackage(entries), + plan, + tokens, + [], + { + tables: [], + listItems: [ + { + ordinal: 1, + text: "一级列表", + depth: 0, + textStartPt: 10 + }, + { + ordinal: 2, + text: "二级代码", + depth: 1, + textStartPt: 25 + } + ] + } + ); + const finalizedXml = decoder.decode( + readGeneratedDocxPackage(result.content).entries.get( + "word/document.xml" + )! + ); + + expect(finalizedXml).toMatch( + /[\s\S]*?[\s\S]*?一级列表<\/w:t>/u + ); + expect(finalizedXml).toMatch( + /[\s\S]*?[\s\S]*?二级<\/w:t>[\s\S]*?代码<\/w:t>/u ); }); diff --git a/packages/docx-engine/tests/font-package-transform.test.ts b/packages/docx-engine/tests/font-package-transform.test.ts index 6c61d2e..119ae9c 100644 --- a/packages/docx-engine/tests/font-package-transform.test.ts +++ b/packages/docx-engine/tests/font-package-transform.test.ts @@ -149,7 +149,7 @@ describe("DOCX 字体包写入", () => { writeGeneratedDocxPackage(invalidEntries) ) ).toThrow("字体元数据与嵌入字形不匹配"); - }); + }, 15_000); it("没有字体时保持原始 DOCX 字节不变", () => { const input = createTestBaselineReference(); diff --git a/packages/docx-engine/tests/pandoc-converter.test.ts b/packages/docx-engine/tests/pandoc-converter.test.ts index 7bc6327..98c92b1 100644 --- a/packages/docx-engine/tests/pandoc-converter.test.ts +++ b/packages/docx-engine/tests/pandoc-converter.test.ts @@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => { expect(arguments_).toContain("--lua-filter"); expect(arguments_).toContain("--data-dir"); expect(arguments_).toContain("--resource-path"); + expect(argumentAfter(arguments_, "--from")).toBe( + "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html" + ); expect( options.env?.MD_TO_PDF_DOCX_MEDIA_MAP ).toContain("media-map.json"); diff --git a/packages/docx-engine/tests/pandoc-structure.test.ts b/packages/docx-engine/tests/pandoc-structure.test.ts index a386d08..981d624 100644 --- a/packages/docx-engine/tests/pandoc-structure.test.ts +++ b/packages/docx-engine/tests/pandoc-structure.test.ts @@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] { } describe("Pandoc 语义结构投影", () => { + it("将简报抬头文字映射到父级抬头样式", () => { + const model: SemanticDocumentModel = { + schemaVersion: 1, + profile: "briefing", + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [{ + kind: "prefix", + nodes: [{ + kind: "group", + role: "briefing-masthead", + children: [text("briefing-masthead-text", "工作简报")] + }] + }] + }; + const plan = createPandocStructurePlan(model, tokens(), { + markerSeed: "briefing" + }); + expect(plan.prefix[0]).toMatchObject({ + kind: "container", + blocks: [{ + kind: "paragraph", + styleId: "MdBriefingMastheadText", + segments: ["工作简报"] + }] + }); + }); + it("将公文分组投影为固定 Word 样式和可编辑段落", () => { const model: SemanticDocumentModel = { schemaVersion: 1, diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index 44c777e..fa54c49 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -272,6 +272,26 @@ describe("动态 reference.docx", () => { } } }, + { + slot: "code-block-text", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Text Face"], + fontSizePt: 10, + color: "#223344" + } + }, + { + slot: "code-token-attribute", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Code Text Face"], + fontSizePt: 10, + color: "#166534" + } + }, { slot: "table", source: "computed-css", @@ -390,6 +410,13 @@ describe("动态 reference.docx", () => { expect(sourceCodeStyle).toMatch( /]*w:left="195"[^>]*w:right="75"[^>]*\/>/u ); + expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"'); + expect(stylesXml).toMatch( + /w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?/u + ); + expect(stylesXml).toMatch( + /w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?[\s\S]*?/u + ); expect(stylesXml).not.toContain('w:val="justify"'); expect(stylesXml).toContain( 'w:line="459" w:lineRule="exact"' @@ -463,6 +490,7 @@ describe("动态 reference.docx", () => { const entries = unzipSync(result.content); const documentXml = decoder.decode(entries["word/document.xml"]); const settingsXml = decoder.decode(entries["word/settings.xml"]); + const stylesXml = decoder.decode(entries["word/styles.xml"]); const headerXml = decoder.decode(entries["word/header1.xml"]); const relationships = decoder.decode( entries["word/_rels/document.xml.rels"] @@ -478,6 +506,7 @@ describe("动态 reference.docx", () => { expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3); expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3); expect(settingsXml).toContain(" { "Source Han Serif SC", "Times New Roman" ]); + expect( + resolveTokenFonts( + { fontCandidates: ["MdTP Mono", "monospace"] }, + { latin: "Arial", eastAsia: "MdTP Sans SC" }, + { preserveFallbackEastAsia: true } + ) + ).toEqual({ + latin: "MdTP Mono", + eastAsia: "MdTP Sans SC", + complexScript: "MdTP Mono" + }); + }); + + it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => { + expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"')) + .toBe("code-token-attribute"); + expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"')) + .toBe("code-token-string"); + expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":")) + .toBe("code-token-punctuation"); + expect(resolvePandocSyntaxStyleSlot("FunctionTok", "render")) + .toBe("code-token-title"); + expect(resolvePandocSyntaxStyleSlot("NormalTok", " ")) + .toBe("code-block-text"); + expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x")) + .toBeUndefined(); }); }); diff --git a/packages/docx-theme-engine/src/normalizer.ts b/packages/docx-theme-engine/src/normalizer.ts index 36bd38f..c7d71cd 100644 --- a/packages/docx-theme-engine/src/normalizer.ts +++ b/packages/docx-theme-engine/src/normalizer.ts @@ -442,34 +442,32 @@ function normalizeComputedSlot( style.spacingAfterPt = spacingAfterPt; } } - if (context.kind !== "inline") { - const padding = { - top: length(context, "padding-top", computed.paddingTop, { - nonNegative: true - }), - right: length(context, "padding-right", computed.paddingRight, { - nonNegative: true - }), - bottom: length(context, "padding-bottom", computed.paddingBottom, { - nonNegative: true - }), - left: length(context, "padding-left", computed.paddingLeft, { - nonNegative: true - }) + const padding = { + top: length(context, "padding-top", computed.paddingTop, { + nonNegative: true + }), + right: length(context, "padding-right", computed.paddingRight, { + nonNegative: true + }), + bottom: length(context, "padding-bottom", computed.paddingBottom, { + nonNegative: true + }), + left: length(context, "padding-left", computed.paddingLeft, { + nonNegative: true + }) + }; + if ( + padding.top !== undefined && + padding.right !== undefined && + padding.bottom !== undefined && + padding.left !== undefined + ) { + style.paddingPt = { + top: padding.top, + right: padding.right, + bottom: padding.bottom, + left: padding.left }; - if ( - padding.top !== undefined && - padding.right !== undefined && - padding.bottom !== undefined && - padding.left !== undefined - ) { - style.paddingPt = { - top: padding.top, - right: padding.right, - bottom: padding.bottom, - left: padding.left - }; - } } const containingBlockWidthPt = computed.containingBlockWidth ? parseCssLengthToPt(computed.containingBlockWidth) @@ -652,7 +650,8 @@ function applyBorderColor( function applyOverrides( slot: DocxStyleSlotName, styleInput: DocxSlotStyleToken, - overrides: DocxThemeStyleOverrides + overrides: DocxThemeStyleOverrides, + replaceExisting: boolean ): { style: DocxSlotStyleToken; applied: boolean; @@ -663,7 +662,10 @@ function applyOverrides( key: Key, value: MutableStyle[Key] | undefined ) => { - if (value !== undefined) { + if ( + value !== undefined && + (replaceExisting || style[key] === undefined) + ) { style[key] = value; applied = true; } @@ -887,7 +889,10 @@ export function resolveDocxThemeTokens( const override = applyOverrides( definition.name, resolved.style, - input.config.overrides + input.config.overrides, + input.config.mode === "explicit" || + !input.config.legacyPreset || + resolved.source === "preset-fallback" ); if (override.applied) { diagnostics.push({ @@ -907,6 +912,43 @@ export function resolveDocxThemeTokens( return resolved; } ); + const blockQuote = slots.find((entry) => entry.slot === "block-quote"); + const blockQuoteText = slots.find( + (entry) => entry.slot === "block-quote-text" + ); + if (blockQuote && blockQuoteText) { + const text = blockQuoteText.style; + blockQuote.style = { + ...blockQuote.style, + ...(text.fontCandidates.length > 0 + ? { fontCandidates: text.fontCandidates } + : {}), + ...(text.fontSizePt !== undefined + ? { fontSizePt: text.fontSizePt } + : {}), + ...(text.bold !== undefined ? { bold: text.bold } : {}), + ...(text.italic !== undefined ? { italic: text.italic } : {}), + ...(text.underline !== undefined + ? { underline: text.underline } + : {}), + ...(text.strikethrough !== undefined + ? { strikethrough: text.strikethrough } + : {}), + ...(text.color !== undefined ? { color: text.color } : {}), + ...(text.lineSpacing !== undefined + ? { lineSpacing: text.lineSpacing } + : {}), + ...(text.letterSpacingPt !== undefined + ? { letterSpacingPt: text.letterSpacingPt } + : {}), + ...(text.alignment !== undefined + ? { alignment: text.alignment } + : {}), + ...(text.firstLineIndentPt !== undefined + ? { firstLineIndentPt: text.firstLineIndentPt } + : {}) + }; + } return docxThemeTokenSetSchema.parse({ schemaVersion: 1, themeId: snapshot.themeId, diff --git a/packages/docx-theme-engine/src/probe.ts b/packages/docx-theme-engine/src/probe.ts index 242c803..7c2f6d8 100644 --- a/packages/docx-theme-engine/src/probe.ts +++ b/packages/docx-theme-engine/src/probe.ts @@ -34,8 +34,8 @@ export function createDocxStyleProbeMarkup(): string {

  • 无序列表
  1. 有序列表
-

引用内容

-
const value = 1;
+

引用内容

+
const key = "value"; true title 1 // comment Array @meta value /x/ {}
@@ -61,7 +61,7 @@ export function createDocxStyleProbeMarkup(): string {
-

工作简报

+

工作简报

第1期示例单位

简报标题

diff --git a/packages/docx-theme-engine/src/runtime.ts b/packages/docx-theme-engine/src/runtime.ts index 609b2b9..05e5f13 100644 --- a/packages/docx-theme-engine/src/runtime.ts +++ b/packages/docx-theme-engine/src/runtime.ts @@ -118,7 +118,6 @@ body { border-radius: 0; background: transparent; box-shadow: none; - font-family: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; diff --git a/packages/docx-theme-engine/src/slots.ts b/packages/docx-theme-engine/src/slots.ts index 48936a0..15949b6 100644 --- a/packages/docx-theme-engine/src/slots.ts +++ b/packages/docx-theme-engine/src/slots.ts @@ -19,7 +19,22 @@ export const DOCX_STYLE_SLOT_NAMES = [ "ordered-list", "list-item", "block-quote", + "block-quote-text", "code-block", + "code-block-text", + "code-token-keyword", + "code-token-literal", + "code-token-string", + "code-token-attribute", + "code-token-title", + "code-token-number", + "code-token-comment", + "code-token-built-in", + "code-token-meta", + "code-token-variable", + "code-token-operator", + "code-token-regexp", + "code-token-punctuation", "table", "table-header", "table-cell", @@ -42,6 +57,7 @@ export const DOCX_STYLE_SLOT_NAMES = [ "official-copy-to", "official-printing-row", "briefing-masthead", + "briefing-masthead-text", "briefing-meta", "briefing-title", "briefing-contact", @@ -121,7 +137,22 @@ const kinds: Record = { "ordered-list": "list", "list-item": "list", "block-quote": "paragraph", + "block-quote-text": "paragraph", "code-block": "paragraph", + "code-block-text": "inline", + "code-token-keyword": "inline", + "code-token-literal": "inline", + "code-token-string": "inline", + "code-token-attribute": "inline", + "code-token-title": "inline", + "code-token-number": "inline", + "code-token-comment": "inline", + "code-token-built-in": "inline", + "code-token-meta": "inline", + "code-token-variable": "inline", + "code-token-operator": "inline", + "code-token-regexp": "inline", + "code-token-punctuation": "inline", table: "table", "table-header": "table", "table-cell": "table", @@ -144,6 +175,7 @@ const kinds: Record = { "official-copy-to": "structure", "official-printing-row": "structure", "briefing-masthead": "structure", + "briefing-masthead-text": "structure", "briefing-meta": "structure", "briefing-title": "structure", "briefing-contact": "structure", diff --git a/packages/docx-theme-engine/tests/normalizer.test.ts b/packages/docx-theme-engine/tests/normalizer.test.ts index fbe0f56..552bd4f 100644 --- a/packages/docx-theme-engine/tests/normalizer.test.ts +++ b/packages/docx-theme-engine/tests/normalizer.test.ts @@ -175,6 +175,12 @@ describe("DOCX 主题令牌归一化", () => { }); expect(findSlot(tokens, "strong").style) .not.toHaveProperty("firstLineIndentPt"); + expect(findSlot(tokens, "inline-code").style.paddingPt).toEqual({ + top: 3, + right: 6, + bottom: 3, + left: 6 + }); expect(findSlot(tokens, "table").style.widthPercent).toBe(100); expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); }); @@ -204,6 +210,43 @@ describe("DOCX 主题令牌归一化", () => { expect(findSlot(tokens, "table").style.widthPercent).toBe(100); }); + it("引用块组合容器盒模型与内部段落文字样式", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot({ + "block-quote": { + fontSize: "24px", + paddingLeft: "20px", + borderLeft: "4px solid rgb(122, 122, 122)" + }, + "block-quote-text": { + fontFamily: '"Merriweather", serif', + fontSize: "16px", + fontStyle: "italic", + lineHeight: "24px", + paddingLeft: "0px", + borderLeft: "0px none rgb(17, 34, 51)" + } + }), + config: { + mode: "auto", + basePreset: "general", + overrides: {}, + legacyPreset: false + } + }); + + expect(findSlot(tokens, "block-quote").style).toMatchObject({ + fontCandidates: ["Merriweather", "serif"], + fontSizePt: 12, + italic: true, + lineSpacing: 1.5, + paddingPt: { left: 15 }, + borders: { + left: { widthPt: 3, color: "#7a7a7a", style: "single" } + } + }); + }); + it("按父级内容区归一化右置百分比结构容器", () => { const tokens = resolveDocxThemeTokens({ snapshot: createSnapshot({ @@ -356,7 +399,7 @@ describe("DOCX 主题令牌归一化", () => { ).toBe(true); }); - it("以清单覆盖自动值并在未命中时显式降级", () => { + it("旧版清单仅补齐自动值,并在未命中时显式降级", () => { const tokens = resolveDocxThemeTokens({ snapshot: createSnapshot({ caption: null }), config: { @@ -376,15 +419,15 @@ describe("DOCX 主题令牌归一化", () => { } }); expect(findSlot(tokens, "paragraph")).toMatchObject({ - source: "manifest-override", + source: "computed-css", confidence: "exact", style: { - fontSizePt: 11, - firstLineIndentPt: 22 + fontSizePt: 12, + firstLineIndentPt: 24 } }); expect(findSlot(tokens, "hyperlink").style).toMatchObject({ - color: "#aa0000", + color: "#112233", underline: false }); expect(findSlot(tokens, "caption")).toMatchObject({ @@ -401,6 +444,24 @@ describe("DOCX 主题令牌归一化", () => { ).toBe(true); }); + it("非旧版自动清单仍可显式覆盖 CSS 计算值", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot(), + config: { + mode: "auto-with-overrides", + basePreset: "formal", + legacyPreset: false, + overrides: { + headings: { color: "#aa0000" } + } + } + }); + expect(findSlot(tokens, "heading-1")).toMatchObject({ + source: "manifest-override", + style: { color: "#aa0000" } + }); + }); + it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => { const tokens = resolveDocxThemeTokens({ snapshot: createSnapshot(), diff --git a/packages/docx-theme-engine/tests/probe.test.ts b/packages/docx-theme-engine/tests/probe.test.ts index f20b5e4..3e46cee 100644 --- a/packages/docx-theme-engine/tests/probe.test.ts +++ b/packages/docx-theme-engine/tests/probe.test.ts @@ -77,6 +77,8 @@ describe("DOCX 标准样式探针", () => { expect(markup).toMatch( /^
]*>\s*

/u ); + expect(markup).toContain(' { diff --git a/packages/docx-theme-engine/tests/runtime.test.ts b/packages/docx-theme-engine/tests/runtime.test.ts index 60bae3e..3607fb4 100644 --- a/packages/docx-theme-engine/tests/runtime.test.ts +++ b/packages/docx-theme-engine/tests/runtime.test.ts @@ -84,6 +84,7 @@ describe("DOCX 主题样式浏览器运行时", () => { expect(script).toContain("contentPaddingLeft"); expect(script).toContain("padding-left: 8px"); expect(script).toContain("#write pre.md-fences \\u003e code"); + expect(script).not.toContain("font-family: inherit"); expect(script).toContain("frame.remove()"); expect(script).not.toContain(""); }); diff --git a/packages/preview-engine/src/docx-media-runtime.ts b/packages/preview-engine/src/docx-media-runtime.ts index 6ca5f7f..910c51a 100644 --- a/packages/preview-engine/src/docx-media-runtime.ts +++ b/packages/preview-engine/src/docx-media-runtime.ts @@ -7,6 +7,11 @@ import { type DocxMediaCaptureTarget, type DocxMediaAlignment, type DocxMediaKind, + type DocxDocumentLayoutPlan, + type DocxInlineCodeLayout, + type DocxListItemLayout, + type DocxTableLayout, + type DocxTextBlockLayout, type PagedDocumentPayload } from "@md-to-pdf/core"; import { PagedDocumentRuntime } from "./paged-document-runtime.js"; @@ -145,6 +150,434 @@ function createGeometryCss(dimensions: DocxMediaRenderDimensions) { `; } +function average(values: readonly number[], fallback: number) { + return values.length > 0 + ? values.reduce((total, value) => total + value, 0) / values.length + : fallback; +} + +function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) { + const rows = Array.from(table.rows); + const columnCount = rows.reduce( + (maximum, row) => + Math.max( + maximum, + Array.from(row.cells).reduce( + (total, cell) => total + Math.max(1, cell.colSpan), + 0 + ) + ), + 0 + ); + if (columnCount < 1) { + return [tableRect.width]; + } + const boundaries = Array.from( + { length: columnCount + 1 }, + () => [] as number[] + ); + boundaries[0]!.push(0); + boundaries[columnCount]!.push(tableRect.width); + for (const row of rows) { + let column = 0; + for (const cell of Array.from(row.cells)) { + const span = Math.max(1, cell.colSpan); + const rect = cell.getBoundingClientRect(); + boundaries[column]?.push( + Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left)) + ); + column = Math.min(columnCount, column + span); + boundaries[column]?.push( + Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left)) + ); + } + } + const resolved = boundaries.map((samples, index) => + average(samples, (tableRect.width * index) / columnCount) + ); + resolved[0] = 0; + resolved[columnCount] = tableRect.width; + for (let index = 1; index < resolved.length; index += 1) { + resolved[index] = Math.max( + resolved[index - 1]! + 0.01, + resolved[index]! + ); + } + return resolved.slice(1).map( + (boundary, index) => boundary - resolved[index]! + ); +} + +function cssColor(value: string) { + const match = value.match( + /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?\s*\)$/iu + ); + if (!match) { + return undefined; + } + const alpha = match[4] === undefined ? 1 : Number(match[4]); + if (!Number.isFinite(alpha) || alpha <= 0.01) { + return undefined; + } + return `#${[match[1], match[2], match[3]] + .map((component) => + Math.max(0, Math.min(255, Math.round(Number(component)))) + .toString(16) + .padStart(2, "0") + ) + .join("")}`; +} + +function effectiveCellBackground( + cell: HTMLTableCellElement, + table: HTMLTableElement +) { + let current: Element | null = cell; + while (current) { + const color = cssColor(getComputedStyle(current).backgroundColor); + if (color) { + return color; + } + if (current === table) { + break; + } + current = current.parentElement; + } + return "#ffffff"; +} + +function cellAlignment(value: string): "left" | "center" | "right" | "justify" { + if (value === "center" || value === "right" || value === "justify") { + return value; + } + return "left"; +} + +const DOCX_TEXT_BLOCK_SELECTOR = + "h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " + + ".doc-classification, .doc-issue-row, .doc-printing-row, " + + ".doc-briefing-meta"; + +function textNodeCharacters(root: HTMLElement) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const characters: Array<{ + node: Text; + start: number; + end: number; + character: string; + }> = []; + let current = walker.nextNode(); + while (current) { + const node = current as Text; + let offset = 0; + for (const character of Array.from(node.data)) { + const end = offset + character.length; + characters.push({ node, start: offset, end, character }); + offset = end; + } + current = walker.nextNode(); + } + return characters; +} + +function characterRect( + range: Range, + character: ReturnType[number] +) { + if (typeof range.getBoundingClientRect !== "function") { + return undefined; + } + range.setStart(character.node, character.start); + range.setEnd(character.node, character.end); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 ? rect : undefined; +} + +export function collectDocxTextBlockLayouts( + article: HTMLElement +): DocxTextBlockLayout[] { + const candidates = Array.from( + article.querySelectorAll(DOCX_TEXT_BLOCK_SELECTOR) + ).filter((element) => { + if (element.closest('[data-semantic-region="cover"], pre')) { + return false; + } + if (element.querySelector("br")) { + return false; + } + return !Array.from( + element.querySelectorAll(DOCX_TEXT_BLOCK_SELECTOR) + ).some((descendant) => descendant !== element); + }); + return candidates.flatMap((element, index) => { + const range = document.createRange(); + const output: string[] = []; + const lineBreakOffsets: number[] = []; + const lineCharacterRects: DOMRect[][] = []; + let previousLineTop: number | undefined; + let previousLineHeight = 0; + let pendingWhitespace = false; + for (const character of textNodeCharacters(element)) { + if (/\s/u.test(character.character)) { + pendingWhitespace = output.length > 0; + continue; + } + const rect = characterRect(range, character); + if (!rect) { + continue; + } + if (pendingWhitespace && output.length > 0) { + output.push(" "); + } + pendingWhitespace = false; + if ( + previousLineTop !== undefined && + Math.abs(rect.top - previousLineTop) > + Math.max(1, Math.min(previousLineHeight, rect.height) * 0.25) + ) { + const offset = output.length; + if (offset > 0 && lineBreakOffsets.at(-1) !== offset) { + lineBreakOffsets.push(offset); + } + lineCharacterRects.push([]); + } else if (lineCharacterRects.length === 0) { + lineCharacterRects.push([]); + } + lineCharacterRects.at(-1)!.push(rect); + output.push(character.character.normalize("NFKC")); + previousLineTop = rect.top; + previousLineHeight = rect.height; + } + const text = output.join("").trim(); + const computed = getComputedStyle(element); + const letterSpacingPx = computed.letterSpacing === "normal" + ? 0 + : Number.parseFloat(computed.letterSpacing); + const elementRect = element.getBoundingClientRect(); + const contentWidth = Math.max( + 0, + elementRect.width - + Number.parseFloat(computed.paddingLeft || "0") - + Number.parseFloat(computed.paddingRight || "0") - + Number.parseFloat(computed.borderLeftWidth || "0") - + Number.parseFloat(computed.borderRightWidth || "0") + ); + const lastLine = lineCharacterRects.at(-1) ?? []; + const sortedLastLine = [...lastLine].sort( + (left, right) => left.left - right.left + ); + const lastLineWidth = sortedLastLine.length > 0 + ? sortedLastLine.at(-1)!.right - sortedLastLine[0]!.left + : 0; + const maximumGap = sortedLastLine.slice(1).reduce( + (largest, rect, rectIndex) => Math.max( + largest, + rect.left - sortedLastLine[rectIndex]!.right + ), + 0 + ); + const fontSizePx = Number.parseFloat(computed.fontSize); + const distributed = + sortedLastLine.length > 1 && + contentWidth > 0 && + lastLineWidth / contentWidth >= 0.85 && + Number.isFinite(fontSizePx) && + maximumGap >= fontSizePx * 0.5; + const lineTops = lineCharacterRects + .map((rects) => rects.length > 0 + ? Math.min(...rects.map((rect) => rect.top)) + : undefined) + .filter((top): top is number => top !== undefined); + const linePitchPx = lineTops.length > 1 + ? lineTops.slice(1).reduce( + (sum, top, lineIndex) => sum + top - lineTops[lineIndex]!, + 0 + ) / (lineTops.length - 1) + : undefined; + return text + ? [{ + ordinal: index + 1, + text, + letterSpacingPt: Number.isFinite(letterSpacingPx) + ? letterSpacingPx * 0.75 + : 0, + alignment: distributed + ? "distribute" as const + : cellAlignment(computed.textAlign), + ...(linePitchPx !== undefined && linePitchPx > 0 + ? { linePitchPt: linePitchPx * 0.75 } + : {}), + lineBreakOffsets + }] + : []; + }); +} + +function listItemCharacters(item: HTMLLIElement) { + return textNodeCharacters(item).filter(({ node }) => { + const parent = node.parentElement; + const nestedList = parent?.closest("ul, ol"); + return nestedList === item.parentElement; + }); +} + +export function collectDocxListItemLayouts( + article: HTMLElement +): DocxListItemLayout[] { + const articleLeft = article.getBoundingClientRect().left; + return Array.from(article.querySelectorAll("li")).flatMap( + (item, index) => { + const range = document.createRange(); + const characters = listItemCharacters(item); + const text = characters + .map(({ character }) => character) + .join("") + .replace(/\s+/gu, " ") + .trim() + .normalize("NFKC"); + const firstVisibleCharacter = characters.find( + (character) => + !/\s/u.test(character.character) && + characterRect(range, character) !== undefined + ); + if (!text || !firstVisibleCharacter) { + return []; + } + const rect = characterRect(range, firstVisibleCharacter); + if (!rect) { + return []; + } + let listDepth = -1; + for ( + let ancestor: Element | null = item.parentElement; + ancestor && article.contains(ancestor); + ancestor = ancestor.parentElement + ) { + if (ancestor.matches("ul, ol")) { + listDepth += 1; + } + } + return [{ + ordinal: index + 1, + text, + depth: Math.max(0, listDepth), + textStartPt: Math.max( + 0, + (rect.left - articleLeft) * 0.75 + ) + }]; + } + ); +} + +function cssPixelsToPoints(value: string) { + const pixels = Number.parseFloat(value); + return Number.isFinite(pixels) ? pixels * 0.75 : 0; +} + +export function collectDocxInlineCodeLayouts( + article: HTMLElement +): DocxInlineCodeLayout[] { + return Array.from( + article.querySelectorAll("code") + ).filter( + (element) => + !element.closest("pre, .md-code-pagination-chunk") + ).flatMap((element, index) => { + const text = (element.textContent ?? "").normalize("NFKC"); + if (!text) { + return []; + } + const computed = getComputedStyle(element); + const fontSizePt = cssPixelsToPoints(computed.fontSize); + if (fontSizePt <= 0) { + return []; + } + const color = cssColor(computed.color) ?? "#000000"; + const backgroundColor = cssColor(computed.backgroundColor); + return [{ + ordinal: index + 1, + text, + fontSizePt, + letterSpacingPt: computed.letterSpacing === "normal" + ? 0 + : cssPixelsToPoints(computed.letterSpacing), + color, + ...(backgroundColor ? { backgroundColor } : {}), + paddingPt: { + top: cssPixelsToPoints(computed.paddingTop), + right: cssPixelsToPoints(computed.paddingRight), + bottom: cssPixelsToPoints(computed.paddingBottom), + left: cssPixelsToPoints(computed.paddingLeft) + }, + borderPt: { + top: cssPixelsToPoints(computed.borderTopWidth), + right: cssPixelsToPoints(computed.borderRightWidth), + bottom: cssPixelsToPoints(computed.borderBottomWidth), + left: cssPixelsToPoints(computed.borderLeftWidth) + } + }]; + }); +} + +export function collectDocxDocumentLayoutPlan( + root: ParentNode, + dimensions: DocxMediaRenderDimensions +): DocxDocumentLayoutPlan { + const article = root.querySelector("#write"); + if (!article) { + throw new Error("DOCX 布局舞台缺少 #write 文档容器"); + } + const articleRect = article.getBoundingClientRect(); + const contentWidth = finitePositive( + articleRect.width, + dimensions.contentWidthPx + ); + const tables = Array.from( + article.querySelectorAll("table") + ).map((table, index): DocxTableLayout => { + const rect = table.getBoundingClientRect(); + const width = finitePositive(rect.width, contentWidth); + const columnWidths = collectTableColumnWidths(table, rect); + const columnTotal = columnWidths.reduce( + (total, value) => total + value, + 0 + ); + return { + ordinal: index + 1, + widthPercent: Math.min(100, (width / contentWidth) * 100), + leftOffsetPercent: Math.max( + 0, + Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100) + ), + columnWidthPercents: columnWidths.map( + (value) => (value / columnTotal) * 100 + ), + rows: Array.from(table.rows).map((row) => ({ + cells: Array.from(row.cells).map((cell) => { + const computed = getComputedStyle(cell); + return { + columnSpan: Math.max(1, cell.colSpan), + backgroundColor: effectiveCellBackground(cell, table), + color: cssColor(computed.color) ?? "#000000", + bold: Number.parseInt(computed.fontWeight, 10) >= 600 || + computed.fontWeight === "bold", + italic: computed.fontStyle === "italic" || + computed.fontStyle === "oblique", + alignment: cellAlignment(computed.textAlign) + }; + }) + })) + }; + }); + return { + tables, + textBlocks: collectDocxTextBlockLayouts(article), + listItems: collectDocxListItemLayouts(article), + inlineCodes: collectDocxInlineCodeLayouts(article) + }; +} + export function collectDocxMediaCaptureTargets( root: ParentNode, dimensions: DocxMediaRenderDimensions @@ -226,7 +659,8 @@ export async function renderDocxMediaCapturePlan( dimensions: DocxMediaRenderDimensions ): Promise { const renderResult = await runtime.renderContinuous(payload, { - geometryCss: createGeometryCss(dimensions) + geometryCss: createGeometryCss(dimensions), + themeMedia: "print" }); if (!renderResult) { throw new Error("DOCX 媒体渲染已取消"); @@ -234,6 +668,7 @@ export async function renderDocxMediaCapturePlan( return { targets: collectDocxMediaCaptureTargets(root, dimensions), echartsErrors: renderResult.echartsErrors, - mermaidErrors: renderResult.mermaidErrors + mermaidErrors: renderResult.mermaidErrors, + documentLayout: collectDocxDocumentLayoutPlan(root, dimensions) }; } diff --git a/packages/preview-engine/src/highlight-styles.ts b/packages/preview-engine/src/highlight-styles.ts new file mode 100644 index 0000000..eff2a09 --- /dev/null +++ b/packages/preview-engine/src/highlight-styles.ts @@ -0,0 +1,19 @@ +export const SHARED_HIGHLIGHT_CSS = ` +pre code.hljs{display:block;overflow-x:auto;padding:1em} +code.hljs{padding:3px 5px} +.hljs{color:#24292e;background:#fff} +.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49} +.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1} +.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5} +.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62} +.hljs-built_in,.hljs-symbol{color:#e36209} +.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d} +.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a} +.hljs-subst{color:#24292e} +.hljs-section{color:#005cc5;font-weight:bold} +.hljs-bullet{color:#735c0f} +.hljs-emphasis{color:#24292e;font-style:italic} +.hljs-strong{color:#24292e;font-weight:bold} +.hljs-addition{color:#22863a;background-color:#f0fff4} +.hljs-deletion{color:#b31d28;background-color:#ffeef0} +`.trim(); diff --git a/packages/preview-engine/src/index.ts b/packages/preview-engine/src/index.ts index 23e67ac..dfbb6d6 100644 --- a/packages/preview-engine/src/index.ts +++ b/packages/preview-engine/src/index.ts @@ -1,5 +1,6 @@ export * from "./diagram-page-fit.js"; export * from "./continuous-preview.js"; +export * from "./highlight-styles.js"; export * from "./document-image-fit.js"; export * from "./docx-media-runtime.js"; export * from "./echarts-page-fit.js"; diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts index 8d26316..faab15d 100644 --- a/packages/preview-engine/src/paged-document-runtime.ts +++ b/packages/preview-engine/src/paged-document-runtime.ts @@ -66,6 +66,7 @@ export interface PagedDocumentRenderOptions { export interface ContinuousDocumentRenderOptions { shouldContinue?: () => boolean; geometryCss?: string; + themeMedia?: "preview" | "print"; } export interface PreviewEngineStyles { @@ -195,6 +196,34 @@ function removeTrailingPdfPageBreak(container: ParentNode) { ?.style.setProperty("break-after", "auto", "important"); } +const PAGED_SPLIT_BLOCK_SELECTOR = [ + "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", + "blockquote", "table", "thead", "tbody", "tfoot", "tr", "th", "td", + "figure", "figcaption", "pre", "section", "article", "div" +].join(","); + +export function removeInheritedPagedSplitJustification( + container: ParentNode +) { + let count = 0; + for (const element of Array.from( + container.querySelectorAll( + '[data-align-last-split-element="justify"]' + ) + )) { + if ( + !Array.from(element.children).some((child) => + child.matches(PAGED_SPLIT_BLOCK_SELECTOR) + ) + ) { + continue; + } + element.removeAttribute("data-align-last-split-element"); + count += 1; + } + return count; +} + function createPagedRenderIdentity( payload: PagedPreviewPayload, options: PagedDocumentRenderOptions @@ -411,7 +440,8 @@ export class PagedDocumentRuntime { private applyContinuousStyles( documentRef: Document, payload: PagedPreviewPayload, - geometryCss = "" + geometryCss = "", + themeMedia: ContinuousDocumentRenderOptions["themeMedia"] = "preview" ) { const style = this.continuousStyle ?? documentRef.createElement("style"); @@ -421,7 +451,9 @@ export class PagedDocumentRuntime { this.styles.highlightCss, this.styles.katexCss, this.styles.echartsCss, - enablePrintMediaForPreview(payload.themeCss), + themeMedia === "preview" + ? enablePrintMediaForPreview(payload.themeCss) + : payload.themeCss, documentInteractionCss, continuousDocumentGeometryCss, geometryCss @@ -897,6 +929,7 @@ export class PagedDocumentRuntime { } const finalizeStartedAt = performance.now(); + removeInheritedPagedSplitJustification(this.root); const pageSequence = classifyPagedPages(this.root, payload); pageCount = pageSequence.physicalPageCount; applyPagedPageDecorations(this.root, payload, pageSequence); @@ -954,7 +987,8 @@ export class PagedDocumentRuntime { this.applyContinuousStyles( documentRef, payload, - options.geometryCss + options.geometryCss, + options.themeMedia ); const template = documentRef.createElement("template"); @@ -969,7 +1003,8 @@ export class PagedDocumentRuntime { const identity = JSON.stringify({ themeCss: payload.themeCss, mermaid: payload.exportConfig.mermaid, - geometryCss: options.geometryCss + geometryCss: options.geometryCss, + themeMedia: options.themeMedia ?? "preview" }); const currentArticle = this.root.querySelector(":scope > #write"); diff --git a/packages/preview-engine/src/paged-preview.ts b/packages/preview-engine/src/paged-preview.ts index 38460a5..12a30c9 100644 --- a/packages/preview-engine/src/paged-preview.ts +++ b/packages/preview-engine/src/paged-preview.ts @@ -95,7 +95,6 @@ svg { border-radius: 0; background: transparent; box-shadow: none; - font-family: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; @@ -548,6 +547,11 @@ html[data-render-target="pdf"] body { min-height: 0 !important; overflow: visible !important; background: #fff !important; + border: 0 !important; + break-before: auto !important; + break-after: auto !important; + page-break-before: auto !important; + page-break-after: auto !important; } html[data-render-target="pdf"] .pagedjs_pages { diff --git a/packages/preview-engine/tests/docx-media-runtime.test.ts b/packages/preview-engine/tests/docx-media-runtime.test.ts index 11b76fb..77b771f 100644 --- a/packages/preview-engine/tests/docx-media-runtime.test.ts +++ b/packages/preview-engine/tests/docx-media-runtime.test.ts @@ -1,7 +1,15 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it } from "vitest"; -import { collectDocxMediaCaptureTargets } from "../src/index.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + collectDocxDocumentLayoutPlan, + collectDocxInlineCodeLayouts, + collectDocxListItemLayouts, + collectDocxTextBlockLayouts, + collectDocxMediaCaptureTargets, + removeInheritedPagedSplitJustification, + renderDocxMediaCapturePlan +} from "../src/index.js"; describe("DOCX 媒体捕获计划", () => { beforeEach(() => { @@ -9,6 +17,74 @@ describe("DOCX 媒体捕获计划", () => { window.scrollTo(0, 0); }); + it("连续布局使用原始打印媒体主题 CSS", async () => { + const renderContinuous = vi.fn(async () => ({ + echartsErrors: [], + mermaidErrors: [] + })); + const root = document.createElement("main"); + root.innerHTML = '
'; + + await renderDocxMediaCapturePlan( + { renderContinuous } as never, + root, + { + articleHtml: '
', + fileName: "打印媒体.md", + metadata: { + title: "", + author: "", + subject: "", + keywords: [], + language: "zh-CN" + }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, + features: [], + themeCss: "@media print { html { font-size: 13px; } }", + exportConfig: {} as never + }, + { contentWidthPx: 640, contentHeightPx: 900 } + ); + + expect(renderContinuous).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ themeMedia: "print" }) + ); + }); + + it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => { + document.body.innerHTML = ` +
+
+

标题

+

跨页正文

+
+
+ `; + expect( + removeInheritedPagedSplitJustification( + document.querySelector("#pages")! + ) + ).toBe(1); + expect( + document.querySelector("#write")?.hasAttribute( + "data-align-last-split-element" + ) + ).toBe(false); + expect( + document.querySelector("p")?.getAttribute( + "data-align-last-split-element" + ) + ).toBe("justify"); + }); + it("按文档顺序标记图片、Mermaid 和 ECharts", () => { document.body.innerHTML = `
@@ -155,4 +231,236 @@ describe("DOCX 媒体捕获计划", () => { ).toBeLessThanOrEqual(4096); expect(target?.altText).toBe("图片 1"); }); + + it("从真实 DOM 几何采集主题无关的表格列宽比例", () => { + document.body.innerHTML = ` +
+

表头
单元格
+ + +
短列较长内容列
12
+ + `; + const article = document.querySelector("#write")!; + const table = document.querySelector("table")!; + const cells = Array.from(table.querySelectorAll("td")); + article.getBoundingClientRect = () => ({ + left: 100, + right: 900, + top: 0, + bottom: 900, + width: 800, + height: 900 + }) as DOMRect; + table.getBoundingClientRect = () => ({ + left: 140, + right: 860, + top: 20, + bottom: 220, + width: 720, + height: 200 + }) as DOMRect; + cells.forEach((cell, index) => { + const firstColumn = index % 2 === 0; + cell.getBoundingClientRect = () => ({ + left: firstColumn ? 140 : 356, + right: firstColumn ? 356 : 860, + top: index < 2 ? 20 : 120, + bottom: index < 2 ? 120 : 220, + width: firstColumn ? 216 : 504, + height: 100 + }) as DOMRect; + }); + + expect( + collectDocxDocumentLayoutPlan(document, { + contentWidthPx: 800, + contentHeightPx: 900 + }) + ).toEqual({ + inlineCodes: [], + listItems: [], + textBlocks: [], + tables: [ + { + ordinal: 1, + widthPercent: 90, + leftOffsetPercent: 5, + columnWidthPercents: [30, 70], + rows: [ + { + cells: [ + { + columnSpan: 1, + backgroundColor: "#ffffff", + color: "#000000", + bold: false, + italic: false, + alignment: "left" + }, + { + columnSpan: 1, + backgroundColor: "#ffffff", + color: "#000000", + bold: false, + italic: false, + alignment: "left" + } + ] + }, + { + cells: [ + { + columnSpan: 1, + backgroundColor: "#ffffff", + color: "#000000", + bold: false, + italic: false, + alignment: "left" + }, + { + columnSpan: 1, + backgroundColor: "#ffffff", + color: "#000000", + bold: false, + italic: false, + alignment: "left" + } + ] + } + ] + } + ] + }); + }); + + it("按真实上下文采集行内代码字号、颜色和盒模型", () => { + document.body.innerHTML = ` +
+

inline

+
block
+
+ `; + expect( + collectDocxInlineCodeLayouts( + document.querySelector("#write")! + ) + ).toEqual([ + { + ordinal: 1, + text: "inline", + fontSizePt: 12, + letterSpacingPt: 0.75, + color: "#112233", + backgroundColor: "#f5f5f5", + paddingPt: { + top: 1.5, + right: 3, + bottom: 1.5, + left: 3 + }, + borderPt: { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + } + ]); + }); + + it("按列表项实际文字起点采集嵌套缩进且排除子列表文字", () => { + document.body.innerHTML = ` +
+
  • 一级 代码
    • 二级项目
+
+ `; + const article = document.querySelector("#write")!; + article.getBoundingClientRect = () => ({ + left: 100, + right: 900, + top: 0, + bottom: 900, + width: 800, + height: 900 + }) as DOMRect; + const rangePrototype = Object.getPrototypeOf(document.createRange()) as { + getBoundingClientRect?: () => DOMRect; + }; + const original = rangePrototype.getBoundingClientRect; + rangePrototype.getBoundingClientRect = function (this: Range) { + const left = this.startContainer.textContent === "二级项目" ? 340 : 220; + return { + left, + right: left + 10, + top: 20, + bottom: 32, + width: 10, + height: 12 + } as DOMRect; + }; + try { + expect(collectDocxListItemLayouts(article)).toEqual([ + { + ordinal: 1, + text: "一级 代码", + depth: 0, + textStartPt: 90 + }, + { + ordinal: 2, + text: "二级项目", + depth: 1, + textStartPt: 180 + } + ]); + } finally { + rangePrototype.getBoundingClientRect = original; + } + }); + + it("采集正文文本块在 Chromium 中的实际行断点", () => { + document.body.innerHTML = ` +
+

独立封面

+

甲乙丙丁

+
+ `; + const rangePrototype = Object.getPrototypeOf(document.createRange()) as { + getBoundingClientRect?: () => DOMRect; + }; + const original = rangePrototype.getBoundingClientRect; + rangePrototype.getBoundingClientRect = function (this: Range) { + const top = this.startOffset < 2 ? 100 : 120; + return { + x: this.startOffset * 10, + y: top, + left: this.startOffset * 10, + right: this.startOffset * 10 + 10, + top, + bottom: top + 12, + width: 10, + height: 12, + toJSON: () => ({}) + } as DOMRect; + }; + try { + expect( + collectDocxTextBlockLayouts( + document.querySelector("#write")! + ) + ).toEqual([ + { + ordinal: 1, + text: "甲乙丙丁", + letterSpacingPt: 0, + alignment: "left", + linePitchPt: 15, + lineBreakOffsets: [2] + } + ]); + } finally { + rangePrototype.getBoundingClientRect = original; + } + }); }); diff --git a/packages/preview-engine/tests/paged-preview.test.ts b/packages/preview-engine/tests/paged-preview.test.ts index a43c2c7..6084712 100644 --- a/packages/preview-engine/tests/paged-preview.test.ts +++ b/packages/preview-engine/tests/paged-preview.test.ts @@ -62,6 +62,7 @@ describe("分页预览协议", () => { expect(css).toContain('html[data-render-target="pdf"]'); expect(css).toContain("height: auto !important"); expect(css).toContain("overflow: visible !important"); + expect(css).toContain("page-break-after: auto !important"); expect(css).toContain("padding: 0"); expect(css).toContain("break-after: page"); expect(css).not.toContain("@media print"); @@ -243,6 +244,9 @@ describe("分页预览协议", () => { ); expect(documentBaseCss).toContain("background: transparent;"); expect(documentBaseCss).toContain("font-size: inherit;"); + expect(documentBaseCss).not.toMatch( + /#write pre\.md-fences > code,[\s\S]*?font-family:\s*inherit;/u + ); expect(documentBaseCss).not.toMatch( /#write pre\.md-fences > code\s*\{[^}]*!important/su ); diff --git a/scripts/build-font-packs.mjs b/scripts/build-font-packs.mjs index e3789cd..93fed62 100644 --- a/scripts/build-font-packs.mjs +++ b/scripts/build-font-packs.mjs @@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const bundlePath = join(repositoryRoot, "font-packs", "bundle.json"); const bundle = JSON.parse(await readFile(bundlePath, "utf8")); const outputRoot = join(repositoryRoot, "output", "font-packs", "root"); -const appVersion = process.env.npm_package_version || "0.6.0"; +const appVersion = process.env.npm_package_version || "0.6.1"; if ( bundle.schemaVersion !== 1 || diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs index 76a3397..a691aea 100644 --- a/scripts/docx-layout-visual-matrix-cases.mjs +++ b/scripts/docx-layout-visual-matrix-cases.mjs @@ -157,9 +157,9 @@ export function getDocxFontGateFailures({ if (embeddedPartCount === 0) { failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`); } - if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) { + if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) { failures.push( - `主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件` + `主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 仅嵌入 ${embeddedPartCount} 个部件` ); } if (embeddedPartCount > 0) { diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs index 6e2c521..b23644d 100644 --- a/scripts/docx-layout-visual-matrix-cases.test.mjs +++ b/scripts/docx-layout-visual-matrix-cases.test.mjs @@ -65,6 +65,20 @@ test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用", engine: "word" }); assert.deepEqual(embedded, []); + + const embeddedWithEngineFaces = getDocxFontGateFailures({ + theme: { + id: "engine-faces", + docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }] + }, + inspection: { + embeddedFontPartCount: 3, + embeddedFontNames: ["MdTP Serif SC", "MdTP Mono"] + }, + renderedFonts: [{ name: "WPSEMBED1" }], + engine: "wps" + }); + assert.deepEqual(embeddedWithEngineFaces, []); }); test("14 套内置主题都声明字体且中文字体面不超过三个", () => { diff --git a/scripts/stage-release.mjs b/scripts/stage-release.mjs index 738722a..884d3a7 100644 --- a/scripts/stage-release.mjs +++ b/scripts/stage-release.mjs @@ -186,6 +186,28 @@ export async function stageRelease(options = {}) { `MorphDoc-${version}-x86_64-Setup.exe`, `MorphDoc-${version}-x86_64.zip` ]; + const fontPackArtifacts = [ + { + name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe", + source: join( + repositoryRoot, + "output", + "font-packs", + "windows", + "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe" + ) + }, + { + name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip", + source: join( + repositoryRoot, + "output", + "font-packs", + "release", + "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip" + ) + } + ]; const releaseNotesSource = join( repositoryRoot, "docs", @@ -195,6 +217,9 @@ export async function stageRelease(options = {}) { for (const name of desktopNames) { await assertReadableFile(join(desktopDirectory, name)); } + for (const artifact of fontPackArtifacts) { + await assertReadableFile(artifact.source); + } await assertReadableFile(releaseNotesSource); await mkdir(stageDirectory, { recursive: true }); @@ -202,6 +227,9 @@ export async function stageRelease(options = {}) { for (const name of desktopNames) { await copyFile(join(desktopDirectory, name), join(stageDirectory, name)); } + for (const artifact of fontPackArtifacts) { + await copyFile(artifact.source, join(stageDirectory, artifact.name)); + } await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md")); const deployName = `MorphDoc-Web-${version}-deploy.zip`; @@ -221,6 +249,7 @@ export async function stageRelease(options = {}) { const primaryNames = [ ...desktopNames, + ...fontPackArtifacts.map((artifact) => artifact.name), imageName, deployName, "RELEASE-NOTES.md" diff --git a/scripts/stage-release.test.mjs b/scripts/stage-release.test.mjs index 1361016..52573eb 100644 --- a/scripts/stage-release.test.mjs +++ b/scripts/stage-release.test.mjs @@ -11,6 +11,11 @@ import { parseArguments } from "./stage-release.mjs"; +const stageReleaseSource = await readFile( + new URL("./stage-release.mjs", import.meta.url), + "utf8" +); + test("normalizeVersion 接受语义版本并移除 v 前缀", () => { assert.equal(normalizeVersion("v0.6.0"), "0.6.0"); assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1"); @@ -55,3 +60,15 @@ test("Web 离线部署 ZIP 包含 Compose 和导入说明", async () => { await rm(directory, { recursive: true, force: true }); } }); + +test("正式发布强制归集独立字体安装器和 ZIP", () => { + assert.match( + stageReleaseSource, + /MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0-x86_64-Setup\.exe/u + ); + assert.match( + stageReleaseSource, + /MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0\.zip/u + ); + assert.match(stageReleaseSource, /fontPackArtifacts\.map/u); +}); diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs index e0ea24d..bb8b380 100644 --- a/scripts/verify-docx-layout-visual-matrix.mjs +++ b/scripts/verify-docx-layout-visual-matrix.mjs @@ -60,11 +60,21 @@ const selectedCaseId = process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined; const selectedScope = process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full"; -const selectedCases = selectedCaseId +const selectedOrientation = + process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined; +const selectedMarginScenarioId = + process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined; +const scopedCases = selectedCaseId ? cases.filter((entry) => entry.id === selectedCaseId) : selectedScope === "cover" ? cases.filter((entry) => entry.coverPageCount > 0) : cases; +const selectedCases = scopedCases.filter( + (entry) => + (!selectedOrientation || entry.orientation === selectedOrientation) && + (!selectedMarginScenarioId || + entry.marginScenarioId === selectedMarginScenarioId) +); function assert(condition, message) { if (!condition) { @@ -121,6 +131,60 @@ function isInternalLayoutSpacerParagraph(descendants, text) { ); } +function hasAncestor(paragraph, localName) { + let current = paragraph.parentNode; + while (current) { + if ( + current.localName === localName && + current.namespaceURI === WORD_NAMESPACE + ) { + return true; + } + current = current.parentNode; + } + return false; +} + +function isTableHeaderParagraph(paragraph) { + let current = paragraph.parentNode; + while (current) { + if (current.localName === "tr" && current.namespaceURI === WORD_NAMESPACE) { + return Array.from(current.getElementsByTagName("*")).some( + (node) => + node.localName === "tblHeader" && + node.namespaceURI === WORD_NAMESPACE + ); + } + current = current.parentNode; + } + return false; +} + +function paragraphBlockKind(paragraph, styleId) { + if (hasAncestor(paragraph, "tc")) { + return isTableHeaderParagraph(paragraph) ? "table-header" : "table-cell"; + } + if (styleId === "SourceCode") { + return "code-block"; + } + if (styleId === "BlockText") { + return "block-quote"; + } + if (/(?:Caption)$/u.test(styleId)) { + return "caption"; + } + if (/^Heading[1-6]$/u.test(styleId)) { + return "heading"; + } + if (styleId === "Compact" || styleId === "ListParagraph") { + return "list-item"; + } + if (/^Md(?:Official|Briefing|ProjectReport|Tender)/u.test(styleId)) { + return "semantic-region"; + } + return "paragraph"; +} + function extractEditableContract(docxPath) { const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath))); const documentXml = entries["word/document.xml"]; @@ -161,6 +225,7 @@ function extractEditableContract(docxPath) { text, ...(styleId ? { styleId } : {}), role: paragraphRole(styleId), + blockKind: paragraphBlockKind(paragraph, styleId), section }); } @@ -217,6 +282,62 @@ function pageGeometryFailures(snapshot, caseDefinition, label) { }); } +const inlineCodeContinuityProbes = [ + { + id: "paragraph", + code: "mdtp_ic_p", + continuousText: "段落前mdtp_ic_p段落后" + }, + { + id: "list-item", + code: "mdtp_ic_l", + continuousText: "列表前mdtp_ic_l列表后" + }, + { + id: "table-cell", + code: "mdtp_ic_c", + continuousText: "单元格前mdtp_ic_c单元格后" + } +]; + +function inspectInlineCodeContinuity(snapshot, label) { + const normalizedLines = snapshot.pages.flatMap((page) => + page.lines.map((line) => ({ + pageNumber: page.pageNumber, + text: line.text, + normalizedText: line.text.normalize("NFKC").replace(/\s+/gu, "") + })) + ); + const probes = inlineCodeContinuityProbes.map((probe) => { + const matchingLines = normalizedLines.filter((line) => + line.normalizedText.includes(probe.code) + ); + const passed = + matchingLines.length === 1 && + matchingLines[0].normalizedText.includes(probe.continuousText); + return { + ...probe, + passed, + matchingLines: matchingLines.map(({ pageNumber, text }) => ({ + pageNumber, + text + })) + }; + }); + return { + label, + passed: probes.every((probe) => probe.passed), + probes, + failures: probes.flatMap((probe) => + probe.passed + ? [] + : [ + `${label}: INLINE_CODE_CONTINUITY_MISMATCH - ${probe.id} 行内代码未与前后文本保持同一视觉行` + ] + ) + }; +} + function buildPageSemantics(pageCount, coverPageCount, config) { assert( pageCount > coverPageCount, @@ -481,6 +602,17 @@ assert( selectedScope === "full" || selectedScope === "cover", `不支持的视觉矩阵作用域:${selectedScope}` ); +assert( + !selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation), + `不支持的视觉矩阵方向:${selectedOrientation}` +); +assert( + !selectedMarginScenarioId || + matrixDefinition.marginScenarios.some( + (entry) => entry.id === selectedMarginScenarioId + ), + `不支持的视觉矩阵边距场景:${selectedMarginScenarioId}` +); const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 }); const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 }); const wordCapability = await wordAdapter.probe(); @@ -578,6 +710,11 @@ for (const caseDefinition of selectedCases) { baselinePageSemantics: wordPageSemantics, candidatePageSemantics: wpsPageSemantics }); + const inlineCodeContinuity = { + chromium: inspectInlineCodeContinuity(chromium, "Chromium"), + word: inspectInlineCodeContinuity(word, "Microsoft Word"), + wps: inspectInlineCodeContinuity(wps, "WPS Writer") + }; const rasterDirectory = path.join(caseDirectory, "raster-pages"); fs.rmSync(rasterDirectory, { recursive: true, force: true }); @@ -605,6 +742,9 @@ for (const caseDefinition of selectedCases) { renderedFonts: wps.fonts, engine: "wps" }), + ...inlineCodeContinuity.chromium.failures, + ...inlineCodeContinuity.word.failures, + ...inlineCodeContinuity.wps.failures, ...reportGateFailures(wordReport, "Chromium/Word"), ...reportGateFailures(wpsReport, "Chromium/WPS"), ...reportGateFailures(officeReport, "Word/WPS") @@ -647,6 +787,7 @@ for (const caseDefinition of selectedCases) { wps: wps.fonts }, editableParagraphCount: editable.paragraphs.length, + inlineCodeContinuity, reports: { word: reportSummary(wordReport), wps: reportSummary(wpsReport), @@ -670,9 +811,13 @@ const summary = { matrixDefinition, selectedCaseId, selectedScope, + selectedOrientation, + selectedMarginScenarioId, execution: { mode: selectedCaseId ? "single-case" + : selectedOrientation || selectedMarginScenarioId + ? "filtered-matrix" : selectedScope === "cover" ? "cover-matrix" : "full-matrix", @@ -693,6 +838,8 @@ const summaryPath = path.join( outputDirectory, selectedCaseId ? `summary-${selectedCaseId}.json` + : selectedOrientation || selectedMarginScenarioId + ? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json` : selectedScope === "cover" ? "summary-cover.json" : "summary.json" @@ -701,6 +848,8 @@ const matrixHtmlPath = path.join( outputDirectory, selectedCaseId ? `matrix-${selectedCaseId}.html` + : selectedOrientation || selectedMarginScenarioId + ? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html` : selectedScope === "cover" ? "matrix-cover.html" : "matrix.html"