From 6e4037420017da00d48ef0fd07f85300f0cc7b44 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Fri, 31 Jul 2026 08:43:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E4=B8=BB=E9=A2=98=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=8E=A5=E5=85=A5=20DOCX=20=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/verify-docx-theme-styles.mjs | 16 +- .../src/desktop-application-controller.ts | 10 +- .../desktop/src/electron-docx-media-engine.ts | 2 + .../scripts/verify-docx-theme-styles.mjs | 150 +++++- apps/server/src/app.ts | 9 +- apps/server/src/docx-media-engine.ts | 2 + apps/server/tests/docx-api.test.ts | 18 +- apps/web/tests/docx-export.test.ts | 1 + docs/PROGRESS.md | 17 +- package-lock.json | 2 + package.json | 2 +- packages/application/README.md | 12 +- packages/application/package.json | 1 + .../application/src/docx-export-service.ts | 53 +- .../src/docx-theme-token-service.ts | 105 ++++ packages/application/src/index.ts | 5 + .../tests/docx-export-service.test.ts | 29 +- .../tests/docx-theme-token-service.test.ts | 142 +++++ packages/core/src/docx.ts | 1 + packages/docx-engine/README.md | 14 +- packages/docx-engine/package.json | 1 + packages/docx-engine/src/index.ts | 1 + packages/docx-engine/src/pandoc-converter.ts | 3 + packages/docx-engine/src/reference-builder.ts | 30 +- packages/docx-engine/src/reference-options.ts | 2 + packages/docx-engine/src/styles-transform.ts | 489 +++++++++++++++++- packages/docx-engine/src/token-style-map.ts | 192 +++++++ .../tests/pandoc-converter.test.ts | 12 + .../tests/reference-builder.test.ts | 127 ++++- .../docx-engine/tests/token-style-map.test.ts | 76 +++ packages/docx-theme-engine/README.md | 5 +- packages/docx-theme-engine/src/normalizer.ts | 4 +- packages/docx-theme-engine/src/runtime.ts | 1 + 33 files changed, 1478 insertions(+), 56 deletions(-) create mode 100644 packages/application/src/docx-theme-token-service.ts create mode 100644 packages/application/tests/docx-theme-token-service.test.ts create mode 100644 packages/docx-engine/src/token-style-map.ts create mode 100644 packages/docx-engine/tests/token-style-map.test.ts diff --git a/apps/desktop/scripts/verify-docx-theme-styles.mjs b/apps/desktop/scripts/verify-docx-theme-styles.mjs index 4251b09..b4aa3f4 100644 --- a/apps/desktop/scripts/verify-docx-theme-styles.mjs +++ b/apps/desktop/scripts/verify-docx-theme-styles.mjs @@ -3,6 +3,7 @@ import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createApplicationService } from "@md-to-pdf/application"; +import { defaultExportConfig } from "@md-to-pdf/core"; import { DOCX_STYLE_SLOT_NAMES, createDocxThemeStyleCaptureScript, @@ -120,8 +121,17 @@ try { const tokenSets = []; for (const theme of bundledThemes) { console.error(`[Electron DOCX theme styles] capturing ${theme.id}`); - const themeCss = await application.getThemeCss(theme.id); - assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`); + const prepared = await application.prepareDocxExport({ + markdown: `# ${theme.name}`, + fileName: `${theme.id}.md`, + language: "zh-CN", + exportConfig: { + ...defaultExportConfig, + themeId: theme.id + } + }); + const themeCss = prepared.theme.css; + const manifest = prepared.theme.manifest; const themeFingerprint = await createDocxThemeStyleFingerprint(theme.id, themeCss); const request = { @@ -150,7 +160,7 @@ try { tokenSets.push( resolveDocxThemeTokens({ snapshot, - config: normalizeDocxThemeMappingConfig(theme) + config: normalizeDocxThemeMappingConfig(manifest) }) ); } diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts index 58406d3..1be3375 100644 --- a/apps/desktop/src/desktop-application-controller.ts +++ b/apps/desktop/src/desktop-application-controller.ts @@ -17,6 +17,7 @@ import { import path from "node:path"; import { DocxExportService, + DocxThemeTokenService, MAXIMUM_MARKDOWN_LENGTH, readDocxExportRuntimeLimits, type ApplicationService @@ -159,6 +160,9 @@ export class DesktopApplicationController { const pandocRuntime = new PandocRuntime({ desktopResourcesPath: options.desktopResourcesPath }); + this.#docxMediaEngine = new ElectronDocxMediaEngine({ + renderUrl: options.docxRenderUrl + }); this.#docxExportService = new DocxExportService({ application: this.#applicationService, runtime: pandocRuntime, @@ -169,11 +173,11 @@ export class DesktopApplicationController { import.meta.url ) }), + themeTokens: new DocxThemeTokenService( + this.#docxMediaEngine + ), limits: readDocxExportRuntimeLimits() }); - this.#docxMediaEngine = new ElectronDocxMediaEngine({ - renderUrl: options.docxRenderUrl - }); } async initialize() { diff --git a/apps/desktop/src/electron-docx-media-engine.ts b/apps/desktop/src/electron-docx-media-engine.ts index e9a01e0..4b02b33 100644 --- a/apps/desktop/src/electron-docx-media-engine.ts +++ b/apps/desktop/src/electron-docx-media-engine.ts @@ -21,6 +21,7 @@ export interface ElectronDocxMediaEngineOptions { export class ElectronDocxMediaEngine implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter { + readonly themeStyleBaseUrl: string; private readonly renderUrl: string; private windowPromise: Promise | undefined; private queue = Promise.resolve(); @@ -28,6 +29,7 @@ export class ElectronDocxMediaEngine constructor(options: ElectronDocxMediaEngineOptions) { this.renderUrl = options.renderUrl; + this.themeStyleBaseUrl = this.renderUrl; } capture( diff --git a/apps/server/scripts/verify-docx-theme-styles.mjs b/apps/server/scripts/verify-docx-theme-styles.mjs index 3d2c2f6..3c812d8 100644 --- a/apps/server/scripts/verify-docx-theme-styles.mjs +++ b/apps/server/scripts/verify-docx-theme-styles.mjs @@ -3,12 +3,22 @@ import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createApplicationService } from "@md-to-pdf/application"; +import { + DOCX_PANDOC_VERSION, + defaultExportConfig +} from "@md-to-pdf/core"; import { DOCX_STYLE_SLOT_NAMES, createDocxThemeStyleFingerprint, normalizeDocxThemeMappingConfig, resolveDocxThemeTokens } from "@md-to-pdf/docx-theme-engine"; +import { + PandocRuntime, + createDynamicReferenceDocx, + readReferenceDocxPackage, + resolveTokenFonts +} from "@md-to-pdf/docx-engine"; import { chromium } from "playwright"; import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js"; @@ -19,6 +29,10 @@ const outputDirectory = path.join( "output", "docx-theme-styles" ); +const referenceDirectory = path.join( + outputDirectory, + "references" +); function assert(condition, message) { if (!condition) { @@ -26,6 +40,81 @@ function assert(condition, message) { } } +function styleBlock(stylesXml, styleId) { + const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return stylesXml.match( + new RegExp( + `]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`, + "u" + ) + )?.[0]; +} + +function inspectReference(reference, tokens) { + const entries = readReferenceDocxPackage(reference.content).entries; + const stylesXml = new TextDecoder().decode( + entries.get("word/styles.xml") + ); + const fontTableXml = new TextDecoder().decode( + entries.get("word/fontTable.xml") + ); + const normal = styleBlock(stylesXml, "Normal"); + const paragraph = tokens.slots.find( + (slot) => slot.slot === "paragraph" + )?.style; + assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`); + const fonts = resolveTokenFonts(paragraph, { + latin: "Arial", + eastAsia: "SimSun" + }); + if (paragraph?.fontCandidates.length) { + assert( + normal.includes(`w:ascii="${fonts.latin}"`), + `主题 ${tokens.themeId} 的 Latin 正文字体未写入模板` + ); + assert( + normal.includes(`w:eastAsia="${fonts.eastAsia}"`), + `主题 ${tokens.themeId} 的东亚正文字体未写入模板` + ); + assert( + fontTableXml.includes(`w:name="${fonts.eastAsia}"`), + `主题 ${tokens.themeId} 的东亚字体未写入字体表` + ); + } + if (paragraph?.fontSizePt !== undefined) { + assert( + normal.includes( + `w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"` + ), + `主题 ${tokens.themeId} 的正文字号未写入模板` + ); + } + for (const styleId of [ + "Heading1", + "SourceCode", + "Table", + "MdOfficialTitle", + "MdBriefingTitle", + "MdProjectReportTitle", + "MdTenderTitle" + ]) { + assert( + stylesXml.includes(`w:styleId="${styleId}"`), + `主题 ${tokens.themeId} 缺少样式 ${styleId}` + ); + } + return { + bytes: reference.content.byteLength, + templateFingerprint: reference.templateFingerprint, + cacheKey: reference.cacheKey, + stylePreset: reference.stylePreset, + partCount: reference.partCount, + paragraphFonts: fonts, + customStyleCount: + stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0 + }; +} + const application = createApplicationService({ bundledRoot: path.join(repositoryDirectory, "themes"), localRoot: path.join(repositoryDirectory, ".local", "themes") @@ -88,6 +177,20 @@ if (!address || typeof address === "string") { } const baseUrl = `http://127.0.0.1:${address.port}/`; const browser = await chromium.launch({ headless: true }); +const runtime = new PandocRuntime( + process.env.DOCX_PANDOC_PATH?.trim() + ? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() } + : {} +); +const capability = await runtime.probe(); +if (capability.capability.status !== "available") { + throw new Error(capability.capability.message); +} +assert( + capability.capability.detectedVersion === DOCX_PANDOC_VERSION, + `Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}` +); +const baseline = await runtime.getDefaultReferenceDocx(); try { const context = await browser.newContext({ @@ -104,10 +207,20 @@ try { ); const snapshots = []; const tokenSets = []; + const references = []; for (const theme of bundledThemes) { console.error(`[DOCX theme styles] capturing ${theme.id}`); - const themeCss = await application.getThemeCss(theme.id); - assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`); + const prepared = await application.prepareDocxExport({ + markdown: `# ${theme.name}`, + fileName: `${theme.id}.md`, + language: "zh-CN", + exportConfig: { + ...defaultExportConfig, + themeId: theme.id + } + }); + const themeCss = prepared.theme.css; + const manifest = prepared.theme.manifest; const themeFingerprint = await createDocxThemeStyleFingerprint(theme.id, themeCss); const snapshot = @@ -129,12 +242,27 @@ try { .join("、")}` ); snapshots.push(snapshot); - tokenSets.push( - resolveDocxThemeTokens({ - snapshot, - config: normalizeDocxThemeMappingConfig(theme) - }) + const tokens = resolveDocxThemeTokens({ + snapshot, + config: normalizeDocxThemeMappingConfig(manifest) + }); + tokenSets.push(tokens); + const reference = createDynamicReferenceDocx(baseline, { + exportConfig: prepared.request.exportConfig, + theme: manifest, + fileName: prepared.request.fileName, + metadata: prepared.document.metadata, + themeTokens: tokens + }); + fs.mkdirSync(referenceDirectory, { recursive: true }); + fs.writeFileSync( + path.join(referenceDirectory, `${theme.id}.docx`), + reference.content ); + references.push({ + themeId: theme.id, + ...inspectReference(reference, tokens) + }); } assert( snapshots.length === 14, @@ -172,7 +300,9 @@ try { themeCount: snapshots.length, slotCount: DOCX_STYLE_SLOT_NAMES.length, snapshots, - tokenSets + tokenSets, + pandocVersion: capability.capability.detectedVersion, + references }, null, 2 @@ -190,7 +320,9 @@ try { diagnostics: tokenSets.reduce( (count, tokens) => count + tokens.diagnostics.length, 0 - ) + ), + pandocVersion: capability.capability.detectedVersion, + references: references.length }) ); await context.close(); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index cdd0276..5e970a2 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -14,6 +14,7 @@ import { ApplicationRequestError, DocxExportService, DocxExportServiceError, + DocxThemeTokenService, createApplicationService, readDocxExportRuntimeLimits, type ApplicationService @@ -133,6 +134,7 @@ function createDocxServerTiming( `queue;dur=${milliseconds(timings.queueMs)}`, `runtime-probe;dur=${milliseconds(timings.probeMs)}`, `prepare;dur=${milliseconds(timings.prepareMs)}`, + `theme-style;dur=${milliseconds(timings.themeStyleMs)}`, `media;dur=${milliseconds(timings.mediaMs)}`, `reference;dur=${milliseconds(timings.referenceMs)}`, `pandoc;dur=${milliseconds(timings.pandocMs)}`, @@ -166,6 +168,8 @@ export function buildApp(options: BuildAppOptions = {}) { const pandocRuntime = options.docxExportService ? undefined : new PandocRuntime(); + const docxMediaAdapter = + options.docxMediaAdapter ?? createDocxMediaEngine(); const docxExportService = options.docxExportService ?? new DocxExportService({ @@ -174,10 +178,11 @@ export function buildApp(options: BuildAppOptions = {}) { converter: new PandocDocxConverter({ runtime: pandocRuntime! }), + themeTokens: new DocxThemeTokenService( + docxMediaAdapter + ), limits: readDocxExportRuntimeLimits() }); - const docxMediaAdapter = - options.docxMediaAdapter ?? createDocxMediaEngine(); if (options.prewarmPdfBrowser) { app.addHook("onReady", async () => { diff --git a/apps/server/src/docx-media-engine.ts b/apps/server/src/docx-media-engine.ts index 43bd04b..65d81e1 100644 --- a/apps/server/src/docx-media-engine.ts +++ b/apps/server/src/docx-media-engine.ts @@ -38,6 +38,7 @@ async function closeContext(context: BrowserContext) { export class PlaywrightDocxMediaEngine implements ServerDocxMediaCaptureAdapter { + readonly themeStyleBaseUrl: string; private readonly renderOrigin: string; private readonly renderUrl: string; private readonly launchBrowser: () => Promise; @@ -54,6 +55,7 @@ export class PlaywrightDocxMediaEngine "/preview-frame.html?target=continuous", this.renderOrigin ).href; + this.themeStyleBaseUrl = this.renderUrl; this.launchBrowser = options.launchBrowser ?? (() => diff --git a/apps/server/tests/docx-api.test.ts b/apps/server/tests/docx-api.test.ts index 85de7d9..682919a 100644 --- a/apps/server/tests/docx-api.test.ts +++ b/apps/server/tests/docx-api.test.ts @@ -42,11 +42,12 @@ function generatedDocx(): DocxExportResult { queueMs: 1, probeMs: 2, prepareMs: 3, - mediaMs: 4, - referenceMs: 5, - pandocMs: 6, - validationMs: 7, - totalMs: 28 + themeStyleMs: 4, + mediaMs: 5, + referenceMs: 6, + pandocMs: 7, + validationMs: 8, + totalMs: 36 } }; } @@ -72,6 +73,8 @@ function createDocxService( function createMediaAdapter() { return { + themeStyleBaseUrl: + "http://localhost:5173/preview-frame.html", capture: vi.fn< DocxMediaCaptureAdapter["capture"] >(async () => ({ @@ -82,6 +85,9 @@ function createMediaAdapter() { }, captures: [] })), + captureThemeStyle: vi.fn(async () => { + throw new Error("此测试使用注入的 DOCX 导出服务"); + }), close: vi.fn(async () => undefined) } satisfies ServerDocxMediaCaptureAdapter; } @@ -173,7 +179,7 @@ describe("DOCX HTTP API", () => { "runtime-probe;dur=2.0" ); expect(response.headers["server-timing"]).toContain( - "pandoc;dur=6.0" + "pandoc;dur=7.0" ); expect(response.rawPayload).toEqual( Buffer.from([80, 75, 3, 4]) diff --git a/apps/web/tests/docx-export.test.ts b/apps/web/tests/docx-export.test.ts index f714bd4..aa062b1 100644 --- a/apps/web/tests/docx-export.test.ts +++ b/apps/web/tests/docx-export.test.ts @@ -133,6 +133,7 @@ describe("DOCX 导出客户端", () => { queueMs: 0, probeMs: 0, prepareMs: 0, + themeStyleMs: 0, mediaMs: 0, referenceMs: 0, pandocMs: 0, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3db5b4f..c35df95 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -193,6 +193,20 @@ Front Matter 和正文首个 H1 构建主题无关的语义树,再由 Renderer `reference.docx` 或最终 DOCX;下一步阶段 12B 将把已归一化的主题令牌 接入动态 Word 模板。 +`v0.6.0` 阶段 12B 已将主题令牌接入真实 DOCX 生产链。应用层现在复用 +Server Playwright 与 Desktop Electron 适配器,按主题 CSS SHA-256 +指纹缓存计算样式快照,并与媒体捕获并行生成 56 槽位令牌;`explicit` +模式直接使用清单覆盖与预设降级,无需启动浏览器。完整令牌进入 Pandoc +转换输入和动态模板缓存键,主题变化不会复用旧模板;诊断进入导出警告, +并新增 `themeStyleMs` 耗时。DOCX 引擎通过固定槽位映射写入标准 Markdown +样式和 31 个 `Md*` 结构样式,同时覆盖字体、字号、颜色、段落、代码、 +引用、表格、图注、链接、字体表与 Office 主题字体,全程不按主题 ID +分支。Playwright Chromium 151 与 Electron Chromium 150 对 14 套主题 +各采集 784 个槽位,完整令牌差异为 0;固定 Pandoc 3.9.0.2 的默认模板 +成功生成并验证 14 份动态 `reference.docx`,每份均含 31 个结构样式。 +当前结构样式尚未绑定到 Pandoc AST,封面、版头、分节与标题去重留给 +阶段 12C。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1124,7 +1138,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌; - 阶段 12A:已建立独立统一语义文档模型,Renderer 与 DOCX 准备结果 共用同一语义树,现有 HTML DOM 保持兼容; -- 阶段 12B:将主题令牌接入动态 `reference.docx`; +- 阶段 12B:已将跨端主题令牌接入动态 `reference.docx`,14 主题双引擎 + 令牌和真实 Pandoc 模板矩阵通过; - 阶段 12C:完成 Pandoc 结构映射、封面分节、标题去重、表格宽度和 分页控制; - 阶段 12D:完成 14 套主题的真实 Word/WPS 视觉与结构回归; diff --git a/package-lock.json b/package-lock.json index 04c5d06..27c017a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9916,6 +9916,7 @@ "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0", + "@md-to-pdf/docx-theme-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { @@ -9938,6 +9939,7 @@ "version": "0.1.0", "dependencies": { "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-theme-engine": "0.1.0", "@xmldom/xmldom": "0.9.10", "fflate": "0.8.3" }, diff --git a/package.json b/package.json index c522415..773eeeb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", "verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine", "verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine", - "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", + "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", "verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts", "verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server", "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", diff --git a/packages/application/README.md b/packages/application/README.md index 602cf10..8f5652b 100644 --- a/packages/application/README.md +++ b/packages/application/README.md @@ -13,6 +13,7 @@ src/ application-service.ts 渲染、主题和资源用例入口 docx-export-service.ts DOCX 并发、超时、错误和完整导出编排 docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装 + docx-theme-token-service.ts 主题指纹、快照缓存和 Word 令牌准备 image-resources.ts 本地、Base64 与受限远程图片处理 theme-registry.ts 内置及自定义主题扫描、校验与缓存 index.ts 公共导出入口 @@ -21,6 +22,7 @@ tests/ bundled-themes.test.ts docx-export-service.test.ts docx-media-service.test.ts + docx-theme-token-service.test.ts image-resources.test.ts ``` @@ -56,10 +58,14 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。 大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png` 清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。 -`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、 -Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒; +`DocxThemeTokenService` 复用 Server Playwright 或 Desktop Electron +适配器采集主题计算样式,按主题 CSS 指纹缓存快照并生成 56 槽位令牌。 +`explicit` 模式不启动浏览器,直接使用清单覆盖和预设降级。 + +`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、主题 +令牌准备、Pandoc 转换和最终 OOXML 校验。媒体与主题令牌并行准备。默认并发为 1、队列为 4、总超时为 90 秒; 可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS` -配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议 +配置。排队、探测、准备、主题样式、媒体、模板、Pandoc、校验和总耗时使用共享协议 返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。 内置主题来自仓库 `themes/`,当前名称为 Typora Github、 diff --git a/packages/application/package.json b/packages/application/package.json index f097891..3a94296 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -23,6 +23,7 @@ "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0", + "@md-to-pdf/docx-theme-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/packages/application/src/docx-export-service.ts b/packages/application/src/docx-export-service.ts index 85f8b1d..efc80d7 100644 --- a/packages/application/src/docx-export-service.ts +++ b/packages/application/src/docx-export-service.ts @@ -20,6 +20,10 @@ import { prepareDocxMedia, type DocxMediaCaptureAdapter } from "./docx-media-service.js"; +import type { + DocxThemeTokenProvider, + PreparedDocxThemeTokens +} from "./docx-theme-token-service.js"; import type { ImageResolutionContext } from "./image-resources.js"; const DEFAULT_DOCX_CONCURRENCY = 1; @@ -47,6 +51,7 @@ export interface DocxExportServiceOptions { application: Pick; runtime: DocxCapabilityProvider; converter: DocxConversionPort; + themeTokens: DocxThemeTokenProvider; limits?: Partial; } @@ -359,24 +364,51 @@ export class DocxExportService { const prepareMs = performance.now() - prepareStarted; controller.signal.throwIfAborted(); - const mediaStarted = performance.now(); - const media = await prepareDocxMedia( - prepared, - options.mediaAdapter, - controller.signal - ); - const mediaMs = performance.now() - mediaStarted; + let themeStyleMs = 0; + let mediaMs = 0; + const [themeTokens, media] = await Promise.all([ + (async () => { + const started = performance.now(); + try { + return await this.options.themeTokens.prepare( + prepared, + controller.signal + ); + } finally { + themeStyleMs = performance.now() - started; + } + })(), + (async () => { + const started = performance.now(); + try { + return await prepareDocxMedia( + prepared, + options.mediaAdapter, + controller.signal + ); + } finally { + mediaMs = performance.now() - started; + } + })() + ]); controller.signal.throwIfAborted(); const conversion = await this.options.converter.convert( - this.createConversionInput(prepared, media), + this.createConversionInput( + prepared, + themeTokens, + media + ), controller.signal ); return { docx: conversion.docx, fileName: createDocxFileName(prepared.request.fileName), diagnostics: { - warnings: media.warnings, + warnings: [ + ...media.warnings, + ...themeTokens.warnings + ], echartsErrors: media.echartsErrors, mermaidErrors: media.mermaidErrors }, @@ -384,6 +416,7 @@ export class DocxExportService { queueMs, probeMs, prepareMs, + themeStyleMs, mediaMs, referenceMs: conversion.timings.referenceMs, pandocMs: conversion.timings.pandocMs, @@ -446,6 +479,7 @@ export class DocxExportService { private createConversionInput( prepared: PreparedDocxExport, + themeTokens: PreparedDocxThemeTokens, media: Awaited> ): PandocDocxConversionInput { return { @@ -454,6 +488,7 @@ export class DocxExportService { language: prepared.request.language, exportConfig: prepared.request.exportConfig, theme: prepared.theme.manifest, + themeTokens: themeTokens.tokens, metadata: prepared.document.metadata, media }; diff --git a/packages/application/src/docx-theme-token-service.ts b/packages/application/src/docx-theme-token-service.ts new file mode 100644 index 0000000..7f4b2af --- /dev/null +++ b/packages/application/src/docx-theme-token-service.ts @@ -0,0 +1,105 @@ +import type { + DocxThemeTokenSet, + DocxThemeStyleCaptureAdapter +} from "@md-to-pdf/docx-theme-engine"; +import { + DOCX_STYLE_SLOTS, + DocxThemeStyleSnapshotCache, + createDocxThemeStyleFingerprint, + normalizeDocxThemeMappingConfig, + resolveDocxThemeTokens, + type DocxThemeStyleSnapshot +} from "@md-to-pdf/docx-theme-engine"; +import type { PreparedDocxExport } from "./application-service.js"; + +export interface PreparedDocxThemeTokens { + tokens: DocxThemeTokenSet; + warnings: string[]; +} + +export interface DocxThemeTokenProvider { + prepare( + prepared: PreparedDocxExport, + signal?: AbortSignal + ): Promise; +} + +function createExplicitSnapshot( + themeId: string, + themeFingerprint: string +): DocxThemeStyleSnapshot { + return { + schemaVersion: 1, + themeId, + themeFingerprint, + viewport: { + widthPx: 794, + heightPx: 1123, + deviceScaleFactor: 1 + }, + rootFontSizePx: 16, + slots: DOCX_STYLE_SLOTS.map(({ name }) => ({ + slot: name, + matched: false + })) + }; +} + +export class DocxThemeTokenService + implements DocxThemeTokenProvider +{ + private readonly snapshots: DocxThemeStyleSnapshotCache; + + constructor( + private readonly adapter: DocxThemeStyleCaptureAdapter + ) { + this.snapshots = new DocxThemeStyleSnapshotCache(adapter); + } + + async prepare( + prepared: PreparedDocxExport, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted(); + const config = normalizeDocxThemeMappingConfig( + prepared.theme.manifest + ); + const themeFingerprint = + await createDocxThemeStyleFingerprint( + prepared.theme.manifest.id, + prepared.theme.css + ); + signal?.throwIfAborted(); + const snapshot = + config.mode === "explicit" + ? createExplicitSnapshot( + prepared.theme.manifest.id, + themeFingerprint + ) + : await this.snapshots.capture( + { + themeId: prepared.theme.manifest.id, + themeFingerprint, + themeCss: prepared.theme.css, + baseUrl: this.adapter.themeStyleBaseUrl + }, + signal + ); + const tokens = resolveDocxThemeTokens({ + snapshot, + config + }); + return { + tokens, + warnings: tokens.diagnostics + .filter( + (diagnostic) => diagnostic.severity !== "info" + ) + .map((diagnostic) => diagnostic.message) + }; + } + + invalidate(themeId?: string) { + this.snapshots.invalidate(themeId); + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 9b0e4a4..878a36b 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -30,6 +30,11 @@ export { type DocxMediaCaptureRequest, type DocxMediaRenderDimensions } from "./docx-media-service.js"; +export { + DocxThemeTokenService, + type DocxThemeTokenProvider, + type PreparedDocxThemeTokens +} from "./docx-theme-token-service.js"; export { DocxExportService, DocxExportServiceError, diff --git a/packages/application/tests/docx-export-service.test.ts b/packages/application/tests/docx-export-service.test.ts index e487891..996f1ea 100644 --- a/packages/application/tests/docx-export-service.test.ts +++ b/packages/application/tests/docx-export-service.test.ts @@ -10,6 +10,7 @@ import type { PandocDocxConversionResult, PandocRuntimeResolution } from "@md-to-pdf/docx-engine"; +import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; import { DocxExportService, readDocxExportRuntimeLimits, @@ -84,6 +85,16 @@ const availableCapability: DocxCapability = { detectedVersion: "3.9.0.2" }; +const themeTokens: DocxThemeTokenSet = { + schemaVersion: 1, + themeId: "test-theme", + themeFingerprint: "c".repeat(64), + mode: "auto-with-overrides", + basePreset: "technical", + slots: [], + diagnostics: [] +}; + function resolution( capability: DocxCapability = availableCapability ): PandocRuntimeResolution { @@ -151,13 +162,22 @@ function createService(options: { options.convert ?? vi.fn().mockResolvedValue(conversionResult()) }, + themeTokens: { + prepare: vi.fn().mockResolvedValue({ + tokens: themeTokens, + warnings: [] + }) + }, limits: options.limits }); } describe("DOCX 共享导出服务", () => { it("串联准备、媒体和转换并返回完整耗时", async () => { - const service = createService({}); + const convert = vi + .fn() + .mockResolvedValue(conversionResult()); + const service = createService({ convert }); const result = await service.generate( { markdown: "# 测试" }, { mediaAdapter: emptyAdapter } @@ -170,7 +190,14 @@ describe("DOCX 共享导出服务", () => { echartsErrors: [], mermaidErrors: [] }); + expect(convert).toHaveBeenCalledWith( + expect.objectContaining({ + themeTokens + }), + expect.any(AbortSignal) + ); expect(result.timings).toMatchObject({ + themeStyleMs: expect.any(Number), referenceMs: 3, pandocMs: 4, validationMs: 5 diff --git a/packages/application/tests/docx-theme-token-service.test.ts b/packages/application/tests/docx-theme-token-service.test.ts new file mode 100644 index 0000000..cd251eb --- /dev/null +++ b/packages/application/tests/docx-theme-token-service.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + defaultExportConfig, + type ThemeManifest +} from "@md-to-pdf/core"; +import { + DOCX_STYLE_SLOTS, + type DocxThemeStyleCaptureAdapter +} from "@md-to-pdf/docx-theme-engine"; +import { + DocxThemeTokenService, + type PreparedDocxExport +} from "../src/index.js"; + +function manifest( + docxStyle: ThemeManifest["docxStyle"] = { + mode: "auto", + basePreset: "technical" + } +): ThemeManifest { + return { + manifestVersion: 1, + id: "test-theme", + name: "测试主题", + version: "1.0.0", + description: "测试", + author: "测试", + license: "内部许可", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + category: "general", + compatibleProfiles: [], + docxStyle, + bundled: true + }; +} + +function prepared( + docxStyle?: ThemeManifest["docxStyle"] +): PreparedDocxExport { + return { + request: { + markdown: "# 测试", + fileName: "测试.md", + language: "zh-CN", + resources: [], + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }, + document: { + rendererVersion: 1, + articleHtml: + '

测试

', + bodyHtml: "

测试

", + metadata: { + title: "测试", + author: "", + subject: "", + keywords: [], + language: "zh-CN" + }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, + features: [], + warnings: [] + }, + theme: { + manifest: manifest(docxStyle), + source: "bundled", + css: "#write { font-family: Test; }" + } + }; +} + +function adapter(): DocxThemeStyleCaptureAdapter { + return { + themeStyleBaseUrl: + "http://localhost:5173/preview-frame.html", + captureThemeStyle: vi.fn(async (request) => ({ + schemaVersion: 1, + themeId: request.themeId, + themeFingerprint: request.themeFingerprint, + viewport: { + widthPx: 794, + heightPx: 1123, + deviceScaleFactor: 1 + }, + rootFontSizePx: 16, + slots: DOCX_STYLE_SLOTS.map(({ name }) => ({ + slot: name, + matched: false + })) + })) + }; +} + +describe("DOCX 主题令牌准备服务", () => { + it("按主题指纹缓存跨端样式快照", async () => { + const captureAdapter = adapter(); + const service = new DocxThemeTokenService(captureAdapter); + + const first = await service.prepare(prepared()); + const second = await service.prepare(prepared()); + + expect(captureAdapter.captureThemeStyle).toHaveBeenCalledTimes(1); + expect(first.tokens).toEqual(second.tokens); + expect(first.tokens.themeId).toBe("test-theme"); + expect(first.tokens.themeFingerprint).toMatch(/^[a-f0-9]{64}$/u); + expect(first.tokens.slots).toHaveLength(DOCX_STYLE_SLOTS.length); + }); + + it("显式模式跳过浏览器采集并只产生信息级降级诊断", async () => { + const captureAdapter = adapter(); + const service = new DocxThemeTokenService(captureAdapter); + + const result = await service.prepare( + prepared({ + mode: "explicit", + basePreset: "technical" + }) + ); + + expect(captureAdapter.captureThemeStyle).not.toHaveBeenCalled(); + expect(result.tokens.mode).toBe("explicit"); + expect(result.warnings).toEqual([]); + expect( + result.tokens.diagnostics.every( + (diagnostic) => diagnostic.severity === "info" + ) + ).toBe(true); + }); +}); diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts index e205f5f..3388f3c 100644 --- a/packages/core/src/docx.ts +++ b/packages/core/src/docx.ts @@ -187,6 +187,7 @@ export interface DocxGenerationTimings { queueMs: number; probeMs: number; prepareMs: number; + themeStyleMs: number; mediaMs: number; referenceMs: number; pandocMs: number; diff --git a/packages/docx-engine/README.md b/packages/docx-engine/README.md index f8f7078..c746a53 100644 --- a/packages/docx-engine/README.md +++ b/packages/docx-engine/README.md @@ -1,8 +1,8 @@ # @md-to-pdf/docx-engine 纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc -版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明 -解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、 +版本的默认 `reference.docx`、验证 ZIP 安全边界,将归一化主题令牌和 +兼容样式预设映射为 Word 样式,并生成纸张、页边距、字体、段落、代码、表格、 页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter 将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown 正文、标题、列表、表格、代码和公式的可编辑文档结构。 @@ -12,7 +12,9 @@ - 不依赖 Fastify、Electron 或浏览器 UI; - ZIP 使用纯 JavaScript `fflate`; - OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML; -- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖; +- 不解析任意主题 CSS,只消费 `@md-to-pdf/docx-theme-engine` 的受限 + 语义令牌;`docxStyle` 预设在槽位缺失时提供兼容降级; +- 槽位只映射到稳定的标准或 `Md*` Word 样式,不包含主题 ID 分支; - 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式; - 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理; - 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸; @@ -37,6 +39,7 @@ npm run build -w @md-to-pdf/docx-engine npm run verify:docx-reference npm run verify:docx-conversion npm run verify:docx-acceptance +npm run verify:docx-theme-styles ``` `verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过 @@ -51,3 +54,8 @@ npm run verify:docx-acceptance `output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会 依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收; 仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`。 + +`verify:docx-theme-styles` 使用 Playwright 与 Electron 分别采集 14 套 +内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认 +模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、 +结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。 diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json index cb02624..5faa860 100644 --- a/packages/docx-engine/package.json +++ b/packages/docx-engine/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-theme-engine": "0.1.0", "@xmldom/xmldom": "0.9.10", "fflate": "0.8.3" }, diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts index f9c96ad..02f0424 100644 --- a/packages/docx-engine/src/index.ts +++ b/packages/docx-engine/src/index.ts @@ -12,4 +12,5 @@ export * from "./reference-package.js"; export * from "./section-transform.js"; export * from "./style-presets.js"; export * from "./styles-transform.js"; +export * from "./token-style-map.js"; export * from "./validator.js"; diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts index 13098e8..42d816f 100644 --- a/packages/docx-engine/src/pandoc-converter.ts +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -17,6 +17,7 @@ import { type PreparedDocxMedia, type ThemeManifest } from "@md-to-pdf/core"; +import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; import { createDynamicReferenceCacheKey, createDynamicReferenceDocx, @@ -57,6 +58,7 @@ export interface PandocDocxConversionInput { language: string; exportConfig: ExportConfig; theme: ThemeManifest; + themeTokens: DocxThemeTokenSet; metadata: MarkdownDocumentMetadata; media: PreparedDocxMedia; } @@ -133,6 +135,7 @@ function referenceOptions( return { exportConfig: input.exportConfig, theme: input.theme, + themeTokens: input.themeTokens, fileName: input.fileName, metadata: { title: input.metadata.title, diff --git a/packages/docx-engine/src/reference-builder.ts b/packages/docx-engine/src/reference-builder.ts index b5f3936..7f2139f 100644 --- a/packages/docx-engine/src/reference-builder.ts +++ b/packages/docx-engine/src/reference-builder.ts @@ -23,6 +23,10 @@ import { transformStylesXml, transformThemeFontsXml } from "./styles-transform.js"; +import { + createDocxTokenSlotMap, + resolveTokenFonts +} from "./token-style-map.js"; import { resolveDocxThemeStyle } from "./style-presets.js"; import { validateDynamicReferenceDocx } from "./validator.js"; @@ -67,6 +71,7 @@ export function createDynamicReferenceCacheKey( docxStyle: options.theme.docxStyle, pageDefaults: options.theme.pageDefaults }, + themeTokens: options.themeTokens, paper: options.exportConfig.paper, pageDecorationsMode: options.exportConfig.pageDecorationsMode, @@ -85,29 +90,46 @@ export function createDynamicReferenceDocx( ): DynamicReferenceDocxResult { const reference = readReferenceDocxPackage(baseline); const style = resolveDocxThemeStyle(options.theme); + const tokenSlots = options.themeTokens + ? createDocxTokenSlotMap(options.themeTokens) + : undefined; + const bodyFonts = resolveTokenFonts( + tokenSlots?.get("paragraph")?.style ?? + tokenSlots?.get("document")?.style, + style.body.fonts + ); const page = resolveReferencePageOptions(options); const withDecorations = createHeaderFooterParts( reference.entries, options, page.header, page.footer, - style.body.fonts.eastAsia, + bodyFonts.eastAsia, page.dimensions.width - page.margins.left - page.margins.right ); const entries = withDecorations.entries; entries.set( "word/styles.xml", - transformStylesXml(entries.get("word/styles.xml")!, style) + transformStylesXml( + entries.get("word/styles.xml")!, + style, + options.themeTokens + ) ); entries.set( "word/fontTable.xml", - transformFontTableXml(entries.get("word/fontTable.xml")!, style) + transformFontTableXml( + entries.get("word/fontTable.xml")!, + style, + options.themeTokens + ) ); entries.set( "word/theme/theme1.xml", transformThemeFontsXml( entries.get("word/theme/theme1.xml")!, - style + style, + options.themeTokens ) ); entries.set( diff --git a/packages/docx-engine/src/reference-options.ts b/packages/docx-engine/src/reference-options.ts index 7454f93..cf572be 100644 --- a/packages/docx-engine/src/reference-options.ts +++ b/packages/docx-engine/src/reference-options.ts @@ -7,12 +7,14 @@ import { type MarkdownDocumentMetadata, type ThemeManifest } from "@md-to-pdf/core"; +import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; export interface DynamicReferenceDocxOptions { exportConfig: ExportConfig; theme: ThemeManifest; fileName: string; metadata: Pick; + themeTokens?: DocxThemeTokenSet; } export function resolveReferencePageOptions( diff --git a/packages/docx-engine/src/styles-transform.ts b/packages/docx-engine/src/styles-transform.ts index bb3f4f2..650adda 100644 --- a/packages/docx-engine/src/styles-transform.ts +++ b/packages/docx-engine/src/styles-transform.ts @@ -1,5 +1,16 @@ import type { DocxFontFamily } from "@md-to-pdf/core"; +import type { + DocxSlotStyleToken, + DocxStyleSlotName, + DocxThemeTokenSet +} from "@md-to-pdf/docx-theme-engine"; import type { ResolvedDocxThemeStyle } from "./style-presets.js"; +import { + DOCX_SLOT_WORD_STYLE_BINDINGS, + collectDocxTokenFonts, + createDocxTokenSlotMap, + resolveTokenFonts +} from "./token-style-map.js"; import { DRAWING_NAMESPACE, WORD_NAMESPACE, @@ -152,6 +163,242 @@ function ensureStyle( return style; } +function setWordAttribute( + element: XmlElement, + name: string, + value: string +) { + element.setAttributeNS(WORD_NAMESPACE, `w:${name}`, value); +} + +function toggleProperty( + parent: XmlElement, + localName: string, + enabled: boolean | undefined +) { + if (enabled === undefined) { + return; + } + removeDirectChildren(parent, WORD_NAMESPACE, localName); + if (enabled) { + appendElement(parent, WORD_NAMESPACE, `w:${localName}`); + } +} + +function fallbackFontsForSlot( + slot: DocxStyleSlotName, + fallback: ResolvedDocxThemeStyle +) { + if (slot.startsWith("heading-") || slot.endsWith("-title")) { + return fallback.headings.fonts; + } + if (slot === "inline-code" || slot === "code-block") { + return fallback.code.fonts; + } + if ( + slot === "table" || + slot === "table-header" || + slot === "table-cell" + ) { + return fallback.table.fonts; + } + if (slot === "caption") { + return fallback.caption.fonts; + } + return fallback.body.fonts; +} + +function applyTokenRunStyle( + parent: XmlElement, + token: DocxSlotStyleToken, + fallbackFonts: DocxFontFamily +) { + if (token.fontCandidates.length) { + setFonts(parent, resolveTokenFonts(token, fallbackFonts)); + } + if (token.fontSizePt !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs"); + const size = pointsToHalfPoints(token.fontSizePt); + appendElement(parent, WORD_NAMESPACE, "w:sz", { + "w:val": size + }); + appendElement(parent, WORD_NAMESPACE, "w:szCs", { + "w:val": size + }); + } + if (token.color !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "color"); + appendElement(parent, WORD_NAMESPACE, "w:color", { + "w:val": colorValue(token.color) + }); + } + if (token.backgroundColor !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "shd"); + appendElement(parent, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(token.backgroundColor) + }); + } + if (token.underline !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "u"); + if (token.underline) { + appendElement(parent, WORD_NAMESPACE, "w:u", { + "w:val": "single" + }); + } + } + if (token.letterSpacingPt !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "spacing"); + appendElement(parent, WORD_NAMESPACE, "w:spacing", { + "w:val": pointsToTwips(token.letterSpacingPt) + }); + } + toggleProperty(parent, "b", token.bold); + toggleProperty(parent, "bCs", token.bold); + toggleProperty(parent, "i", token.italic); + toggleProperty(parent, "iCs", token.italic); + toggleProperty(parent, "strike", token.strikethrough); + toggleProperty(parent, "vanish", token.hidden); +} + +function applyTokenBorders( + parent: XmlElement, + token: DocxSlotStyleToken +) { + if (!token.borders) { + return; + } + removeDirectChildren(parent, WORD_NAMESPACE, "pBdr"); + const borders = appendElement( + parent, + WORD_NAMESPACE, + "w:pBdr" + ); + for (const side of [ + "top", + "right", + "bottom", + "left" + ] as const) { + const border = token.borders[side]; + if (!border) { + continue; + } + appendElement(borders, WORD_NAMESPACE, `w:${side}`, { + "w:val": border.style, + "w:sz": String( + Math.max(0, Math.min(160, Math.round(border.widthPt * 8))) + ), + "w:space": String( + Math.max( + 0, + Math.min( + 31, + Math.round(token.paddingPt?.[side] ?? 0) + ) + ) + ), + "w:color": colorValue(border.color) + }); + } +} + +function applyTokenParagraphStyle( + parent: XmlElement, + token: DocxSlotStyleToken +) { + if ( + token.spacingBeforePt !== undefined || + token.spacingAfterPt !== undefined || + token.lineSpacing !== undefined + ) { + const spacing = ensureDirectElement( + parent, + WORD_NAMESPACE, + "w:spacing" + ); + if (token.spacingBeforePt !== undefined) { + setWordAttribute( + spacing, + "before", + pointsToTwips(token.spacingBeforePt) + ); + } + if (token.spacingAfterPt !== undefined) { + setWordAttribute( + spacing, + "after", + pointsToTwips(token.spacingAfterPt) + ); + } + if (token.lineSpacing !== undefined) { + setWordAttribute( + spacing, + "line", + String(Math.round(token.lineSpacing * 240)) + ); + setWordAttribute(spacing, "lineRule", "auto"); + } + } + if ( + token.firstLineIndentPt !== undefined || + token.leftIndentPt !== undefined || + token.rightIndentPt !== undefined + ) { + const indent = ensureDirectElement( + parent, + WORD_NAMESPACE, + "w:ind" + ); + if (token.firstLineIndentPt !== undefined) { + setWordAttribute( + indent, + "firstLine", + pointsToTwips(token.firstLineIndentPt) + ); + indent.removeAttributeNS(WORD_NAMESPACE, "firstLineChars"); + } + if (token.leftIndentPt !== undefined) { + setWordAttribute( + indent, + "left", + pointsToTwips(token.leftIndentPt) + ); + indent.removeAttributeNS(WORD_NAMESPACE, "leftChars"); + } + if (token.rightIndentPt !== undefined) { + setWordAttribute( + indent, + "right", + pointsToTwips(token.rightIndentPt) + ); + } + } + if (token.alignment !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "jc"); + appendElement(parent, WORD_NAMESPACE, "w:jc", { + "w:val": token.alignment + }); + } + if (token.backgroundColor !== undefined) { + removeDirectChildren(parent, WORD_NAMESPACE, "shd"); + appendElement(parent, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(token.backgroundColor) + }); + } + applyTokenBorders(parent, token); + toggleProperty(parent, "keepLines", token.keepLines); + toggleProperty(parent, "keepNext", token.keepWithNext); + toggleProperty( + parent, + "pageBreakBefore", + token.pageBreakBefore + ); +} + function applyBodyStyle( styles: XmlElement, style: ResolvedDocxThemeStyle @@ -589,9 +836,215 @@ function applyTableAndCaption( ); } +function applyTableTokenStyles( + styles: XmlElement, + slots: ReadonlyMap< + DocxStyleSlotName, + { style: DocxSlotStyleToken } + >, + fallback: ResolvedDocxThemeStyle +) { + const tableToken = slots.get("table")?.style; + const cellToken = + slots.get("table-cell")?.style ?? tableToken; + const headerToken = + slots.get("table-header")?.style ?? cellToken; + if (!tableToken && !cellToken && !headerToken) { + return; + } + const table = ensureStyle( + styles, + "Table", + "table", + "TableNormal" + ); + const tblPr = ensureDirectElement( + table, + WORD_NAMESPACE, + "w:tblPr" + ); + if (tableToken?.widthPercent !== undefined) { + removeDirectChildren(tblPr, WORD_NAMESPACE, "tblW"); + appendElement(tblPr, WORD_NAMESPACE, "w:tblW", { + "w:w": String( + Math.round(tableToken.widthPercent * 50) + ), + "w:type": "pct" + }); + } + const padding = cellToken?.paddingPt ?? tableToken?.paddingPt; + if (padding) { + removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar"); + const margins = appendElement( + tblPr, + WORD_NAMESPACE, + "w:tblCellMar" + ); + for (const side of [ + "top", + "right", + "bottom", + "left" + ] as const) { + appendElement(margins, WORD_NAMESPACE, `w:${side}`, { + "w:w": pointsToTwips(padding[side]), + "w:type": "dxa" + }); + } + } + if (tableToken?.borders) { + removeDirectChildren(tblPr, WORD_NAMESPACE, "tblBorders"); + const borders = appendElement( + tblPr, + WORD_NAMESPACE, + "w:tblBorders" + ); + for (const side of [ + "top", + "right", + "bottom", + "left" + ] as const) { + const border = tableToken.borders[side]; + if (!border) { + continue; + } + appendElement(borders, WORD_NAMESPACE, `w:${side}`, { + "w:val": border.style, + "w:sz": String(Math.round(border.widthPt * 8)), + "w:space": "0", + "w:color": colorValue(border.color) + }); + } + } + if (cellToken) { + applyTokenRunStyle( + ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"), + cellToken, + fallback.table.fonts + ); + } + let firstRow = directChildren( + table, + WORD_NAMESPACE, + "tblStylePr" + ).find( + (element) => + element.getAttributeNS(WORD_NAMESPACE, "type") === "firstRow" + ); + if (!firstRow) { + firstRow = appendElement( + table, + WORD_NAMESPACE, + "w:tblStylePr", + { "w:type": "firstRow" } + ); + } + if (headerToken) { + applyTokenRunStyle( + ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"), + headerToken, + fallback.table.fonts + ); + if (headerToken.backgroundColor !== undefined) { + const tcPr = ensureDirectElement( + firstRow, + WORD_NAMESPACE, + "w:tcPr" + ); + removeDirectChildren(tcPr, WORD_NAMESPACE, "shd"); + appendElement(tcPr, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue( + headerToken.backgroundColor + ) + }); + } + } +} + +function applyTokenStyles( + styles: XmlElement, + tokens: DocxThemeTokenSet, + fallback: ResolvedDocxThemeStyle +) { + const slots = createDocxTokenSlotMap(tokens); + const documentToken = slots.get("document")?.style; + if (documentToken) { + const defaults = firstDirectChild( + styles, + WORD_NAMESPACE, + "docDefaults" + ); + if (defaults) { + applyTokenRunStyle( + ensureDirectElement( + ensureDirectElement( + defaults, + WORD_NAMESPACE, + "w:rPrDefault" + ), + WORD_NAMESPACE, + "w:rPr" + ), + documentToken, + fallback.body.fonts + ); + applyTokenParagraphStyle( + ensureDirectElement( + ensureDirectElement( + defaults, + WORD_NAMESPACE, + "w:pPrDefault" + ), + WORD_NAMESPACE, + "w:pPr" + ), + documentToken + ); + } + } + for (const [slot, entry] of slots) { + const bindings = DOCX_SLOT_WORD_STYLE_BINDINGS[slot]; + if (!bindings) { + continue; + } + for (const binding of bindings) { + const target = ensureStyle( + styles, + binding.styleId, + binding.type, + binding.basedOn + ); + if (binding.type === "paragraph") { + applyTokenParagraphStyle( + ensureDirectElement( + target, + WORD_NAMESPACE, + "w:pPr" + ), + entry.style + ); + } + applyTokenRunStyle( + ensureDirectElement( + target, + WORD_NAMESPACE, + "w:rPr" + ), + entry.style, + fallbackFontsForSlot(slot, fallback) + ); + } + } + applyTableTokenStyles(styles, slots, fallback); +} + export function transformStylesXml( content: Uint8Array, - style: ResolvedDocxThemeStyle + style: ResolvedDocxThemeStyle, + tokens?: DocxThemeTokenSet ) { const document = parseXmlPart(content, "word/styles.xml"); const styles = document.documentElement!; @@ -605,12 +1058,16 @@ export function transformStylesXml( applyHeadings(styles, style); applyCodeAndQuote(styles, style); applyTableAndCaption(styles, style); + if (tokens) { + applyTokenStyles(styles, tokens, style); + } return serializeXmlPart(document); } export function transformFontTableXml( content: Uint8Array, - style: ResolvedDocxThemeStyle + style: ResolvedDocxThemeStyle, + tokens?: DocxThemeTokenSet ) { const document = parseXmlPart(content, "word/fontTable.xml"); const root = document.documentElement!; @@ -628,6 +1085,11 @@ export function transformFontTableXml( requiredFonts.add(fonts.complexScript); } } + if (tokens) { + for (const font of collectDocxTokenFonts(tokens)) { + requiredFonts.add(font); + } + } const existing = new Set( Array.from( root.getElementsByTagNameNS(WORD_NAMESPACE, "font") @@ -648,9 +1110,22 @@ export function transformFontTableXml( export function transformThemeFontsXml( content: Uint8Array, - style: ResolvedDocxThemeStyle + style: ResolvedDocxThemeStyle, + tokens?: DocxThemeTokenSet ) { const document = parseXmlPart(content, "word/theme/theme1.xml"); + const slots = tokens + ? createDocxTokenSlotMap(tokens) + : undefined; + const headingFonts = resolveTokenFonts( + slots?.get("heading-1")?.style, + style.headings.fonts + ); + const bodyFonts = resolveTokenFonts( + slots?.get("paragraph")?.style ?? + slots?.get("document")?.style, + style.body.fonts + ); for (const schemeName of ["majorFont", "minorFont"]) { const scheme = document.getElementsByTagNameNS( DRAWING_NAMESPACE, @@ -670,14 +1145,14 @@ export function transformThemeFontsXml( latin?.setAttribute( "typeface", schemeName === "majorFont" - ? style.headings.fonts.latin - : style.body.fonts.latin + ? headingFonts.latin + : bodyFonts.latin ); eastAsia?.setAttribute( "typeface", schemeName === "majorFont" - ? style.headings.fonts.eastAsia - : style.body.fonts.eastAsia + ? headingFonts.eastAsia + : bodyFonts.eastAsia ); } return serializeXmlPart(document); diff --git a/packages/docx-engine/src/token-style-map.ts b/packages/docx-engine/src/token-style-map.ts new file mode 100644 index 0000000..d95fc31 --- /dev/null +++ b/packages/docx-engine/src/token-style-map.ts @@ -0,0 +1,192 @@ +import type { DocxFontFamily } from "@md-to-pdf/core"; +import { + docxThemeTokenSetSchema, + type DocxResolvedStyleSlot, + type DocxSlotStyleToken, + type DocxStyleSlotName, + type DocxThemeTokenSet +} from "@md-to-pdf/docx-theme-engine"; + +export interface DocxWordStyleBinding { + styleId: string; + type: "paragraph" | "character"; + basedOn?: string; +} + +function paragraph( + styleId: string, + basedOn = "Normal" +): DocxWordStyleBinding { + return { styleId, type: "paragraph", basedOn }; +} + +function character( + styleId: string, + basedOn = "DefaultParagraphFont" +): DocxWordStyleBinding { + return { styleId, type: "character", basedOn }; +} + +export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly< + Partial< + Record< + DocxStyleSlotName, + readonly DocxWordStyleBinding[] + > + > +> = { + "document-title": [paragraph("Title")], + "document-author": [paragraph("Author")], + paragraph: [ + paragraph("Normal", ""), + paragraph("BodyText"), + paragraph("FirstParagraph"), + paragraph("Definition"), + paragraph("Figure") + ], + strong: [character("Strong")], + emphasis: [character("Emphasis")], + strikethrough: [character("Strikeout")], + "inline-code": [character("VerbatimChar", "BodyTextChar")], + "heading-1": [ + paragraph("Heading1"), + character("Heading1Char") + ], + "heading-2": [ + paragraph("Heading2"), + character("Heading2Char") + ], + "heading-3": [ + paragraph("Heading3"), + character("Heading3Char") + ], + "heading-4": [ + paragraph("Heading4"), + character("Heading4Char") + ], + "heading-5": [ + paragraph("Heading5"), + character("Heading5Char") + ], + "heading-6": [ + paragraph("Heading6"), + character("Heading6Char") + ], + "unordered-list": [paragraph("MdUnorderedList", "BodyText")], + "ordered-list": [paragraph("MdOrderedList", "BodyText")], + "list-item": [ + paragraph("Compact", "BodyText"), + paragraph("ListParagraph", "BodyText") + ], + "block-quote": [paragraph("BlockText", "BodyText")], + "code-block": [paragraph("SourceCode")], + hyperlink: [character("Hyperlink", "BodyTextChar")], + figure: [paragraph("Figure")], + caption: [ + paragraph("Caption"), + paragraph("TableCaption", "Caption"), + paragraph("ImageCaption", "Caption") + ], + footnotes: [paragraph("FootnoteText")], + "official-masthead": [paragraph("MdOfficialMasthead")], + "official-classification": [ + paragraph("MdOfficialClassification") + ], + "official-issuer": [paragraph("MdOfficialIssuer")], + "official-issue-row": [paragraph("MdOfficialIssueRow")], + "official-number": [paragraph("MdOfficialNumber")], + "official-signatory": [paragraph("MdOfficialSignatory")], + "official-title": [paragraph("MdOfficialTitle")], + "official-signature": [paragraph("MdOfficialSignature")], + "official-edition": [paragraph("MdOfficialEdition")], + "briefing-masthead": [paragraph("MdBriefingMasthead")], + "briefing-meta": [paragraph("MdBriefingMeta")], + "briefing-title": [paragraph("MdBriefingTitle")], + "briefing-contact": [paragraph("MdBriefingContact")], + "project-report-cover": [paragraph("MdProjectReportCover")], + "project-report-project-name": [ + paragraph("MdProjectReportProjectName") + ], + "project-report-title": [paragraph("MdProjectReportTitle")], + "project-report-version": [ + paragraph("MdProjectReportVersion") + ], + "project-report-owner": [paragraph("MdProjectReportOwner")], + "project-report-prepared-by": [ + paragraph("MdProjectReportPreparedBy") + ], + "project-report-date": [paragraph("MdProjectReportDate")], + "tender-cover": [paragraph("MdTenderCover")], + "tender-copy-mark": [paragraph("MdTenderCopyMark")], + "tender-project-name": [paragraph("MdTenderProjectName")], + "tender-project-number": [ + paragraph("MdTenderProjectNumber") + ], + "tender-title": [paragraph("MdTenderTitle")], + "tender-volume": [paragraph("MdTenderVolume")], + "tender-bidder": [paragraph("MdTenderBidder")], + "tender-representative": [ + paragraph("MdTenderRepresentative") + ], + "tender-date": [paragraph("MdTenderDate")] +}; + +const eastAsiaFontPattern = + /(?:yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu; +const genericFontNames = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui" +]); + +export function resolveTokenFonts( + token: DocxSlotStyleToken | undefined, + fallback: DocxFontFamily +): DocxFontFamily { + const candidates = (token?.fontCandidates ?? []).filter( + (candidate) => + !genericFontNames.has(candidate.trim().toLowerCase()) + ); + if (!candidates.length) { + return fallback; + } + const eastAsia = + candidates.find((candidate) => + eastAsiaFontPattern.test(candidate) + ) ?? candidates[0]!; + const latin = + candidates.find( + (candidate) => !eastAsiaFontPattern.test(candidate) + ) ?? candidates[0]!; + return { + latin, + eastAsia, + complexScript: latin + }; +} + +export function createDocxTokenSlotMap( + input: DocxThemeTokenSet +): ReadonlyMap { + const tokens = docxThemeTokenSetSchema.parse(input); + return new Map( + tokens.slots.map((entry) => [entry.slot, entry]) + ); +} + +export function collectDocxTokenFonts( + input: DocxThemeTokenSet +): Set { + const fonts = new Set(); + for (const { style } of input.slots) { + for (const candidate of style.fontCandidates) { + if (!genericFontNames.has(candidate.trim().toLowerCase())) { + fonts.add(candidate); + } + } + } + return fonts; +} diff --git a/packages/docx-engine/tests/pandoc-converter.test.ts b/packages/docx-engine/tests/pandoc-converter.test.ts index 05faf85..a40bcc4 100644 --- a/packages/docx-engine/tests/pandoc-converter.test.ts +++ b/packages/docx-engine/tests/pandoc-converter.test.ts @@ -13,6 +13,7 @@ import { type PreparedDocxMedia, type ThemeManifest } from "@md-to-pdf/core"; +import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; import { PandocDocxConversionError, PandocDocxConverter, @@ -49,6 +50,16 @@ const emptyMedia: PreparedDocxMedia = { totalBytes: 0 }; +const themeTokens: DocxThemeTokenSet = { + schemaVersion: 1, + themeId: "test-theme", + themeFingerprint: "c".repeat(64), + mode: "auto-with-overrides", + basePreset: "technical", + slots: [], + diagnostics: [] +}; + function input() { return { markdown: "# 测试", @@ -56,6 +67,7 @@ function input() { language: "zh-CN", exportConfig: defaultExportConfig, theme: theme(), + themeTokens, metadata: { title: "测试", author: "测试人", diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index 2f6d89f..1acebdd 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -6,6 +6,10 @@ import { type ExportConfig, type ThemeManifest } from "@md-to-pdf/core"; +import type { + DocxResolvedStyleSlot, + DocxThemeTokenSet +} from "@md-to-pdf/docx-theme-engine"; import { createDynamicReferenceDocx, validateGeneratedDocx, @@ -129,6 +133,21 @@ function theme( }; } +function themeTokens( + fingerprint = "c".repeat(64), + slots: DocxResolvedStyleSlot[] = [] +): DocxThemeTokenSet { + return { + schemaVersion: 1, + themeId: "test-theme", + themeFingerprint: fingerprint, + mode: "auto-with-overrides", + basePreset: "technical", + slots, + diagnostics: [] + }; +} + function createOptions(exportConfig: ExportConfig) { return { exportConfig, @@ -137,7 +156,8 @@ function createOptions(exportConfig: ExportConfig) { metadata: { title: "年度 <报告>", author: "测试人" - } + }, + themeTokens: themeTokens() }; } @@ -159,6 +179,10 @@ describe("动态 reference.docx", () => { author: "测试人" } }); + const changedTokens = createDynamicReferenceDocx(baseline, { + ...createOptions(defaultExportConfig), + themeTokens: themeTokens("d".repeat(64)) + }); const entries = unzipSync(first.content); const documentXml = decoder.decode(entries["word/document.xml"]); const stylesXml = decoder.decode(entries["word/styles.xml"]); @@ -169,6 +193,7 @@ describe("动态 reference.docx", () => { ); expect(first.cacheKey).toBe(second.cacheKey); expect(first.cacheKey).not.toBe(changedMetadata.cacheKey); + expect(first.cacheKey).not.toBe(changedTokens.cacheKey); expect(documentXml).toContain('w:w="11906"'); expect(documentXml).toContain('w:h="16838"'); expect(documentXml).toContain('w:top="907"'); @@ -186,6 +211,106 @@ describe("动态 reference.docx", () => { }); }); + it("将通用主题令牌写入标准样式、结构样式和字体部件", () => { + const result = createDynamicReferenceDocx( + createBaselineReference(), + { + ...createOptions(defaultExportConfig), + themeTokens: themeTokens("e".repeat(64), [ + { + slot: "paragraph", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: [ + "Source Han Serif SC", + "Times New Roman" + ], + fontSizePt: 14, + color: "#112233", + lineSpacing: 1.8, + firstLineIndentPt: 28 + } + }, + { + slot: "heading-1", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["SimHei", "Arial"], + fontSizePt: 22, + bold: true, + color: "#aa0000", + alignment: "center", + keepWithNext: true + } + }, + { + slot: "table", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: [], + widthPercent: 100, + paddingPt: { + top: 3, + right: 4, + bottom: 3, + left: 4 + }, + borders: { + top: { + widthPt: 1, + color: "#445566", + style: "single" + } + } + } + }, + { + slot: "official-title", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["SimSun"], + fontSizePt: 22, + color: "#ff0000", + alignment: "center", + keepWithNext: true + } + } + ]) + } + ); + const entries = unzipSync(result.content); + const stylesXml = decoder.decode(entries["word/styles.xml"]); + const fontTableXml = decoder.decode( + entries["word/fontTable.xml"] + ); + const themeXml = decoder.decode( + entries["word/theme/theme1.xml"] + ); + + expect(stylesXml).toContain('w:styleId="MdOfficialTitle"'); + expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"'); + expect(stylesXml).toContain('w:ascii="Times New Roman"'); + expect(stylesXml).toContain('w:val="112233"'); + expect(stylesXml).toContain('w:styleId="Heading1"'); + expect(stylesXml).toContain('w:val="AA0000"'); + expect(stylesXml).toContain('w:w="5000" w:type="pct"'); + expect(stylesXml).toContain('w:color="445566"'); + expect(fontTableXml).toContain( + 'w:name="Source Han Serif SC"' + ); + expect(fontTableXml).toContain('w:name="SimHei"'); + expect(themeXml).toContain( + ' { const exportConfig: ExportConfig = { ...defaultExportConfig, diff --git a/packages/docx-engine/tests/token-style-map.test.ts b/packages/docx-engine/tests/token-style-map.test.ts new file mode 100644 index 0000000..d8e4bbe --- /dev/null +++ b/packages/docx-engine/tests/token-style-map.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine"; +import { + DOCX_SLOT_WORD_STYLE_BINDINGS, + collectDocxTokenFonts, + createDocxTokenSlotMap, + resolveTokenFonts +} from "../src/index.js"; + +const tokens: DocxThemeTokenSet = { + schemaVersion: 1, + themeId: "test-theme", + themeFingerprint: "a".repeat(64), + mode: "auto", + basePreset: "general", + slots: [ + { + slot: "paragraph", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: [ + "Source Han Serif SC", + "Times New Roman", + "serif" + ] + } + } + ], + diagnostics: [] +}; + +describe("DOCX 令牌样式映射", () => { + it("以稳定样式 ID 覆盖标准 Markdown 和结构槽位", () => { + expect( + DOCX_SLOT_WORD_STYLE_BINDINGS["heading-1"] + ).toEqual([ + { + styleId: "Heading1", + type: "paragraph", + basedOn: "Normal" + }, + { + styleId: "Heading1Char", + type: "character", + basedOn: "DefaultParagraphFont" + } + ]); + expect( + DOCX_SLOT_WORD_STYLE_BINDINGS["official-title"]?.[0] + ?.styleId + ).toBe("MdOfficialTitle"); + expect( + DOCX_SLOT_WORD_STYLE_BINDINGS["tender-representative"]?.[0] + ?.styleId + ).toBe("MdTenderRepresentative"); + }); + + it("按文字系统选择字体并过滤 CSS 通用族名", () => { + const slot = createDocxTokenSlotMap(tokens).get("paragraph"); + expect( + resolveTokenFonts(slot?.style, { + latin: "Arial", + eastAsia: "SimSun" + }) + ).toEqual({ + latin: "Times New Roman", + eastAsia: "Source Han Serif SC", + complexScript: "Times New Roman" + }); + expect([...collectDocxTokenFonts(tokens)]).toEqual([ + "Source Han Serif SC", + "Times New Roman" + ]); + }); +}); diff --git a/packages/docx-theme-engine/README.md b/packages/docx-theme-engine/README.md index 96bd541..8948c8b 100644 --- a/packages/docx-theme-engine/README.md +++ b/packages/docx-theme-engine/README.md @@ -29,6 +29,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立 Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期 中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。 -`@md-to-pdf/docx-engine` 后续消费本包令牌生成 `reference.docx` 和最终 -OOXML。Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似 +`@md-to-pdf/application` 已负责指纹、快照缓存和令牌准备, +`@md-to-pdf/docx-engine` 已消费本包令牌生成动态 `reference.docx`。 +Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似 令牌与诊断,供语义文档结构层选择 Word 表格、分节或段落布局实现。 diff --git a/packages/docx-theme-engine/src/normalizer.ts b/packages/docx-theme-engine/src/normalizer.ts index 313e7e0..9980803 100644 --- a/packages/docx-theme-engine/src/normalizer.ts +++ b/packages/docx-theme-engine/src/normalizer.ts @@ -759,7 +759,9 @@ export function resolveDocxThemeTokens( style: normalized.style }; } else { - const missing = entry?.matched !== true; + const missing = + input.config.mode !== "explicit" && + entry?.matched !== true; diagnostics.push({ severity: missing ? "warning" : "info", code: diff --git a/packages/docx-theme-engine/src/runtime.ts b/packages/docx-theme-engine/src/runtime.ts index d84c10d..bb5dfc0 100644 --- a/packages/docx-theme-engine/src/runtime.ts +++ b/packages/docx-theme-engine/src/runtime.ts @@ -25,6 +25,7 @@ export type DocxThemeStyleCaptureRequest = z.infer< >; export interface DocxThemeStyleCaptureAdapter { + readonly themeStyleBaseUrl: string; captureThemeStyle( request: DocxThemeStyleCaptureRequest, signal?: AbortSignal