From 6fed666f44405f54455d9abe92970e1e4307989c Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 20:28:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20DOCX=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=98=A0=E5=B0=84=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 13 +- package-lock.json | 15 ++ package.json | 9 +- packages/core/src/docx-style.ts | 37 +++- packages/core/tests/docx.test.ts | 22 +++ packages/docx-engine/src/style-presets.ts | 42 +++-- packages/docx-theme-engine/README.md | 25 +++ packages/docx-theme-engine/package.json | 30 ++++ .../docx-theme-engine/src/configuration.ts | 104 +++++++++++ packages/docx-theme-engine/src/index.ts | 4 + packages/docx-theme-engine/src/slots.ts | 149 ++++++++++++++++ packages/docx-theme-engine/src/snapshot.ts | 90 ++++++++++ packages/docx-theme-engine/src/tokens.ts | 158 +++++++++++++++++ .../docx-theme-engine/tests/contracts.test.ts | 162 ++++++++++++++++++ packages/docx-theme-engine/tsconfig.json | 16 ++ 15 files changed, 851 insertions(+), 25 deletions(-) create mode 100644 packages/docx-theme-engine/README.md create mode 100644 packages/docx-theme-engine/package.json create mode 100644 packages/docx-theme-engine/src/configuration.ts create mode 100644 packages/docx-theme-engine/src/index.ts create mode 100644 packages/docx-theme-engine/src/slots.ts create mode 100644 packages/docx-theme-engine/src/snapshot.ts create mode 100644 packages/docx-theme-engine/src/tokens.ts create mode 100644 packages/docx-theme-engine/tests/contracts.test.ts create mode 100644 packages/docx-theme-engine/tsconfig.json diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3aa2af1..33b0dfd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -152,6 +152,14 @@ DOCX 样式令牌,支持自动映射、清单覆盖和预设降级;Front Mat 主题 ID 分支,普通外部主题即使没有 `docxStyle` 也应获得可诊断的基础 DOCX 映射。 +`v0.6.0` 阶段 11 的协议骨架已建立为独立 +`packages/docx-theme-engine`:定义 50 个稳定语义样式槽位、浏览器计算 +样式快照、Word 目标样式令牌、属性来源、映射置信度和诊断协议。 +`docxStyle` 支持 `auto`、`auto-with-overrides`、`explicit` 三种模式, +普通外部主题缺少 DOCX 声明时默认进入自动映射;现有主题的 `preset` 和 +扁平样式覆盖保持兼容。当前只建立公共契约与 workspace 构建边界,尚未 +实现样式探针页面、Chromium/Electron 采集器或接入 Pandoc 转换链路。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1076,8 +1084,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: Desktop 保存验收; - 阶段 10:已完成 Word 原生页眉页脚和第一轮全主题视觉审计;完整主题 映射门禁未通过; -- 阶段 11:建立独立 `packages/docx-theme-engine`、标准样式探针和 - CSS 计算样式到 DOCX 令牌的通用映射; +- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及 + 快照、令牌、来源、置信度和诊断协议;下一步实现样式探针与 + Chromium/Electron 计算样式采集; - 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、 表格宽度和分页控制; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 diff --git a/package-lock.json b/package-lock.json index 1d872fe..4c6e088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2189,6 +2189,10 @@ "resolved": "packages/docx-engine", "link": true }, + "node_modules/@md-to-pdf/docx-theme-engine": { + "resolved": "packages/docx-theme-engine", + "link": true + }, "node_modules/@md-to-pdf/markdown-echarts": { "resolved": "packages/markdown-echarts", "link": true @@ -9936,6 +9940,17 @@ "vitest": "^4.1.10" } }, + "packages/docx-theme-engine": { + "name": "@md-to-pdf/docx-theme-engine", + "version": "0.1.0", + "dependencies": { + "@md-to-pdf/core": "0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "vitest": "^4.1.10" + } + }, "packages/markdown-echarts": { "name": "@md-to-pdf/markdown-echarts", "version": "0.1.0", diff --git a/package.json b/package.json index 88b9fbe..146dbe3 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,11 @@ ], "scripts": { "build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", - "build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && 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/preview-engine && npm run build -w @md-to-pdf/web", - "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && 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/preview-engine && concurrently -k -n markdown-echarts,core,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", + "build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && 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/preview-engine && npm run build -w @md-to-pdf/web", + "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && 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/preview-engine && concurrently -k -n markdown-echarts,core,docx-theme-engine,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:docx-theme-engine\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", "dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts", "dev:core": "npm run dev -w @md-to-pdf/core", + "dev:docx-theme-engine": "npm run dev -w @md-to-pdf/docx-theme-engine", "dev:docx-engine": "npm run dev -w @md-to-pdf/docx-engine", "dev:renderer": "npm run dev -w @md-to-pdf/renderer", "dev:application": "npm run dev -w @md-to-pdf/application", @@ -30,8 +31,8 @@ "verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine", "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/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", - "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" + "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/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", + "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { "node": ">=22" diff --git a/packages/core/src/docx-style.ts b/packages/core/src/docx-style.ts index c9f1108..c4bfc23 100644 --- a/packages/core/src/docx-style.ts +++ b/packages/core/src/docx-style.ts @@ -27,6 +27,16 @@ export type DocxStylePreset = z.infer< typeof docxStylePresetSchema >; +export const docxThemeStyleModeSchema = z.enum([ + "auto", + "auto-with-overrides", + "explicit" +]); + +export type DocxThemeStyleMode = z.infer< + typeof docxThemeStyleModeSchema +>; + export const docxFontFamilySchema = z.object({ latin: fontNameSchema, eastAsia: fontNameSchema, @@ -37,7 +47,7 @@ export type DocxFontFamily = z.infer< typeof docxFontFamilySchema >; -const docxBodyStyleSchema = z.object({ +export const docxBodyStyleSchema = z.object({ fonts: docxFontFamilySchema.optional(), sizePt: pointSizeSchema.optional(), color: colorSchema.optional(), @@ -47,7 +57,7 @@ const docxBodyStyleSchema = z.object({ spacingAfterPt: spacingSchema.optional() }); -const docxHeadingStyleSchema = z.object({ +export const docxHeadingStyleSchema = z.object({ fonts: docxFontFamilySchema.optional(), sizesPt: z .tuple([ @@ -65,7 +75,7 @@ const docxHeadingStyleSchema = z.object({ spacingAfterPt: spacingSchema.optional() }); -const docxCodeStyleSchema = z.object({ +export const docxCodeStyleSchema = z.object({ fonts: docxFontFamilySchema.optional(), sizePt: pointSizeSchema.optional(), color: colorSchema.optional(), @@ -74,7 +84,7 @@ const docxCodeStyleSchema = z.object({ lineSpacing: z.number().min(1).max(3).optional() }); -const docxBlockStyleSchema = z.object({ +export const docxBlockStyleSchema = z.object({ color: colorSchema.optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), @@ -82,7 +92,7 @@ const docxBlockStyleSchema = z.object({ italic: z.boolean().optional() }); -const docxTableStyleSchema = z.object({ +export const docxTableStyleSchema = z.object({ fonts: docxFontFamilySchema.optional(), sizePt: pointSizeSchema.optional(), color: colorSchema.optional(), @@ -92,7 +102,7 @@ const docxTableStyleSchema = z.object({ cellMarginMm: z.number().min(0).max(20).optional() }); -const docxCaptionStyleSchema = z.object({ +export const docxCaptionStyleSchema = z.object({ fonts: docxFontFamilySchema.optional(), sizePt: pointSizeSchema.optional(), color: colorSchema.optional(), @@ -100,8 +110,7 @@ const docxCaptionStyleSchema = z.object({ alignment: z.enum(["left", "center", "right"]).optional() }); -export const docxThemeStyleSchema = z.object({ - preset: docxStylePresetSchema, +export const docxThemeStyleOverridesSchema = z.object({ body: docxBodyStyleSchema.optional(), headings: docxHeadingStyleSchema.optional(), code: docxCodeStyleSchema.optional(), @@ -116,6 +125,18 @@ export const docxThemeStyleSchema = z.object({ .optional() }); +export type DocxThemeStyleOverrides = z.infer< + typeof docxThemeStyleOverridesSchema +>; + +export const docxThemeStyleSchema = + docxThemeStyleOverridesSchema.extend({ + mode: docxThemeStyleModeSchema.optional(), + preset: docxStylePresetSchema.optional(), + basePreset: docxStylePresetSchema.optional(), + overrides: docxThemeStyleOverridesSchema.optional() + }); + export type DocxThemeStyle = z.infer< typeof docxThemeStyleSchema >; diff --git a/packages/core/tests/docx.test.ts b/packages/core/tests/docx.test.ts index d784bf5..dd042fe 100644 --- a/packages/core/tests/docx.test.ts +++ b/packages/core/tests/docx.test.ts @@ -209,4 +209,26 @@ describe("DOCX 主题样式协议", () => { }) ).toThrow(); }); + + it("接受自动映射模式和兼容预设", () => { + expect( + docxThemeStyleSchema.parse({ + mode: "auto-with-overrides", + basePreset: "technical", + overrides: { + table: { + headerBackgroundColor: "#f6f8fa" + } + } + }) + ).toMatchObject({ + mode: "auto-with-overrides", + basePreset: "technical", + overrides: { + table: { + headerBackgroundColor: "#f6f8fa" + } + } + }); + }); }); diff --git a/packages/docx-engine/src/style-presets.ts b/packages/docx-engine/src/style-presets.ts index d9cc368..4ce1bc7 100644 --- a/packages/docx-engine/src/style-presets.ts +++ b/packages/docx-engine/src/style-presets.ts @@ -234,25 +234,45 @@ export function resolveDocxThemeStyle( preset: inferDocxStylePreset(manifest) } ); - const preset = presetOverrides[declared.preset]; + const presetName = + declared.basePreset ?? + declared.preset ?? + inferDocxStylePreset(manifest); + const preset = presetOverrides[presetName]; + const overrides = declared.overrides; const base = { ...baseStyle, ...preset }; return { - preset: declared.preset, - body: mergeSection(base.body, declared.body), - headings: mergeSection(base.headings, declared.headings), - code: mergeSection(base.code, declared.code), + preset: presetName, + body: mergeSection( + mergeSection(base.body, declared.body), + overrides?.body + ), + headings: mergeSection( + mergeSection(base.headings, declared.headings), + overrides?.headings + ), + code: mergeSection( + mergeSection(base.code, declared.code), + overrides?.code + ), blockQuote: mergeSection( - base.blockQuote, - declared.blockQuote + mergeSection(base.blockQuote, declared.blockQuote), + overrides?.blockQuote + ), + table: mergeSection( + mergeSection(base.table, declared.table), + overrides?.table + ), + caption: mergeSection( + mergeSection(base.caption, declared.caption), + overrides?.caption ), - table: mergeSection(base.table, declared.table), - caption: mergeSection(base.caption, declared.caption), hyperlink: mergeSection( - base.hyperlink, - declared.hyperlink + mergeSection(base.hyperlink, declared.hyperlink), + overrides?.hyperlink ) }; } diff --git a/packages/docx-theme-engine/README.md b/packages/docx-theme-engine/README.md new file mode 100644 index 0000000..42ddc8a --- /dev/null +++ b/packages/docx-theme-engine/README.md @@ -0,0 +1,25 @@ +# @md-to-pdf/docx-theme-engine + +DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立稳定、可诊断 +的中间协议。 + +当前阶段包含: + +- 标准 Markdown 与结构化文档语义槽位; +- Chromium/Electron 计算样式快照协议; +- Word 目标样式令牌; +- 自动映射、显式覆盖和预设降级配置; +- 样式来源、映射置信度和诊断协议; +- 旧版 `docxStyle.preset` 兼容归一化。 + +本包不负责: + +- 启动 Playwright 或 Electron; +- 读取主题目录或网络资源; +- 执行 Pandoc; +- 直接修改 OOXML; +- 按主题 ID 维护专属转换分支。 + +后续 Chromium 与 Electron 适配器只负责采集标准样式探针的 +`getComputedStyle()` 结果。本包负责将快照归一化为 DOCX 样式令牌, +`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。 diff --git a/packages/docx-theme-engine/package.json b/packages/docx-theme-engine/package.json new file mode 100644 index 0000000..b615740 --- /dev/null +++ b/packages/docx-theme-engine/package.json @@ -0,0 +1,30 @@ +{ + "name": "@md-to-pdf/docx-theme-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + }, + "dependencies": { + "@md-to-pdf/core": "0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "vitest": "^4.1.10" + } +} diff --git a/packages/docx-theme-engine/src/configuration.ts b/packages/docx-theme-engine/src/configuration.ts new file mode 100644 index 0000000..fd71aeb --- /dev/null +++ b/packages/docx-theme-engine/src/configuration.ts @@ -0,0 +1,104 @@ +import { + docxThemeStyleOverridesSchema, + type DocxStylePreset, + type DocxThemeStyleMode, + type DocxThemeStyleOverrides, + type ThemeManifest +} from "@md-to-pdf/core"; + +export interface NormalizedDocxThemeMappingConfig { + mode: DocxThemeStyleMode; + basePreset: DocxStylePreset; + overrides: DocxThemeStyleOverrides; + legacyPreset: boolean; +} + +function inferBasePreset( + manifest: Pick +): DocxStylePreset { + if (manifest.category === "red-letter") { + return "official"; + } + if (manifest.category === "formal") { + return "formal"; + } + if (manifest.category === "tender") { + return "tender"; + } + return "general"; +} + +function mergeSection( + legacy: T | undefined, + current: T | undefined +): T | undefined { + if (!legacy && !current) { + return undefined; + } + return { + ...legacy, + ...current + } as T; +} + +export function normalizeDocxThemeMappingConfig( + manifest: ThemeManifest +): NormalizedDocxThemeMappingConfig { + const style = manifest.docxStyle; + const legacyPreset = + style?.preset !== undefined && + style.mode === undefined && + style.basePreset === undefined; + const mode = + style?.mode ?? + (legacyPreset ? "explicit" : "auto"); + const basePreset = + style?.basePreset ?? + style?.preset ?? + inferBasePreset(manifest); + const legacyOverrides = docxThemeStyleOverridesSchema.parse({ + body: style?.body, + headings: style?.headings, + code: style?.code, + blockQuote: style?.blockQuote, + table: style?.table, + caption: style?.caption, + hyperlink: style?.hyperlink + }); + const overrides = docxThemeStyleOverridesSchema.parse({ + body: mergeSection( + legacyOverrides.body, + style?.overrides?.body + ), + headings: mergeSection( + legacyOverrides.headings, + style?.overrides?.headings + ), + code: mergeSection( + legacyOverrides.code, + style?.overrides?.code + ), + blockQuote: mergeSection( + legacyOverrides.blockQuote, + style?.overrides?.blockQuote + ), + table: mergeSection( + legacyOverrides.table, + style?.overrides?.table + ), + caption: mergeSection( + legacyOverrides.caption, + style?.overrides?.caption + ), + hyperlink: mergeSection( + legacyOverrides.hyperlink, + style?.overrides?.hyperlink + ) + }); + return { + mode, + basePreset, + overrides, + legacyPreset + }; +} diff --git a/packages/docx-theme-engine/src/index.ts b/packages/docx-theme-engine/src/index.ts new file mode 100644 index 0000000..78ab043 --- /dev/null +++ b/packages/docx-theme-engine/src/index.ts @@ -0,0 +1,4 @@ +export * from "./configuration.js"; +export * from "./slots.js"; +export * from "./snapshot.js"; +export * from "./tokens.js"; diff --git a/packages/docx-theme-engine/src/slots.ts b/packages/docx-theme-engine/src/slots.ts new file mode 100644 index 0000000..566d353 --- /dev/null +++ b/packages/docx-theme-engine/src/slots.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; + +export const DOCX_STYLE_SLOT_NAMES = [ + "document", + "document-title", + "document-author", + "paragraph", + "strong", + "emphasis", + "strikethrough", + "inline-code", + "heading-1", + "heading-2", + "heading-3", + "heading-4", + "heading-5", + "heading-6", + "unordered-list", + "ordered-list", + "list-item", + "block-quote", + "code-block", + "table", + "table-header", + "table-cell", + "hyperlink", + "figure", + "image", + "caption", + "footnotes", + "official-masthead", + "official-classification", + "official-issuer", + "official-issue-row", + "official-number", + "official-signatory", + "official-signature", + "official-edition", + "briefing-masthead", + "briefing-meta", + "briefing-contact", + "cover", + "cover-copy-mark", + "cover-project-name", + "cover-project-number", + "cover-title", + "cover-volume", + "cover-owner", + "cover-prepared-by", + "cover-bidder", + "cover-representative", + "cover-version", + "cover-date" +] as const; + +export const docxStyleSlotNameSchema = z.enum( + DOCX_STYLE_SLOT_NAMES +); + +export type DocxStyleSlotName = z.infer< + typeof docxStyleSlotNameSchema +>; + +export const docxStyleSlotKindSchema = z.enum([ + "document", + "paragraph", + "inline", + "list", + "table", + "media", + "structure" +]); + +export type DocxStyleSlotKind = z.infer< + typeof docxStyleSlotKindSchema +>; + +export interface DocxStyleSlotDefinition { + name: DocxStyleSlotName; + kind: DocxStyleSlotKind; + selector: string; +} + +function slot( + name: DocxStyleSlotName, + kind: DocxStyleSlotKind +): DocxStyleSlotDefinition { + return { + name, + kind, + selector: `[data-docx-slot="${name}"]` + }; +} + +const kinds: Record = { + document: "document", + "document-title": "paragraph", + "document-author": "paragraph", + paragraph: "paragraph", + strong: "inline", + emphasis: "inline", + strikethrough: "inline", + "inline-code": "inline", + "heading-1": "paragraph", + "heading-2": "paragraph", + "heading-3": "paragraph", + "heading-4": "paragraph", + "heading-5": "paragraph", + "heading-6": "paragraph", + "unordered-list": "list", + "ordered-list": "list", + "list-item": "list", + "block-quote": "paragraph", + "code-block": "paragraph", + table: "table", + "table-header": "table", + "table-cell": "table", + hyperlink: "inline", + figure: "media", + image: "media", + caption: "paragraph", + footnotes: "paragraph", + "official-masthead": "structure", + "official-classification": "structure", + "official-issuer": "structure", + "official-issue-row": "structure", + "official-number": "structure", + "official-signatory": "structure", + "official-signature": "structure", + "official-edition": "structure", + "briefing-masthead": "structure", + "briefing-meta": "structure", + "briefing-contact": "structure", + cover: "structure", + "cover-copy-mark": "structure", + "cover-project-name": "structure", + "cover-project-number": "structure", + "cover-title": "structure", + "cover-volume": "structure", + "cover-owner": "structure", + "cover-prepared-by": "structure", + "cover-bidder": "structure", + "cover-representative": "structure", + "cover-version": "structure", + "cover-date": "structure" +}; + +export const DOCX_STYLE_SLOTS: readonly DocxStyleSlotDefinition[] = + DOCX_STYLE_SLOT_NAMES.map((name) => slot(name, kinds[name])); diff --git a/packages/docx-theme-engine/src/snapshot.ts b/packages/docx-theme-engine/src/snapshot.ts new file mode 100644 index 0000000..bb9037e --- /dev/null +++ b/packages/docx-theme-engine/src/snapshot.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import { docxStyleSlotNameSchema } from "./slots.js"; + +const cssValueSchema = z.string().trim().min(1).max(500); + +export const docxComputedStyleSchema = z.object({ + fontFamily: cssValueSchema, + fontSize: cssValueSchema, + fontWeight: cssValueSchema, + fontStyle: cssValueSchema, + color: cssValueSchema, + backgroundColor: cssValueSchema, + lineHeight: cssValueSchema, + letterSpacing: cssValueSchema, + textAlign: cssValueSchema, + textIndent: cssValueSchema, + textDecorationLine: cssValueSchema, + marginTop: cssValueSchema, + marginRight: cssValueSchema, + marginBottom: cssValueSchema, + marginLeft: cssValueSchema, + paddingTop: cssValueSchema, + paddingRight: cssValueSchema, + paddingBottom: cssValueSchema, + paddingLeft: cssValueSchema, + borderTop: cssValueSchema, + borderRight: cssValueSchema, + borderBottom: cssValueSchema, + borderLeft: cssValueSchema, + width: cssValueSchema, + maxWidth: cssValueSchema, + breakBefore: cssValueSchema, + breakAfter: cssValueSchema, + breakInside: cssValueSchema, + display: cssValueSchema +}); + +export type DocxComputedStyle = z.infer< + typeof docxComputedStyleSchema +>; + +export const docxStyleSlotSnapshotSchema = z.object({ + slot: docxStyleSlotNameSchema, + matched: z.boolean(), + computed: docxComputedStyleSchema.optional() +}); + +export type DocxStyleSlotSnapshot = z.infer< + typeof docxStyleSlotSnapshotSchema +>; + +export const docxThemeStyleSnapshotSchema = z + .object({ + schemaVersion: z.literal(1), + themeId: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u), + themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u), + viewport: z.object({ + widthPx: z.number().int().positive().max(10000), + heightPx: z.number().int().positive().max(10000), + deviceScaleFactor: z.number().positive().max(10) + }), + rootFontSizePx: z.number().positive().max(200), + slots: z.array(docxStyleSlotSnapshotSchema).min(1) + }) + .superRefine((snapshot, context) => { + const names = new Set(); + for (const entry of snapshot.slots) { + if (names.has(entry.slot)) { + context.addIssue({ + code: "custom", + message: `样式快照包含重复槽位:${entry.slot}`, + path: ["slots"] + }); + } + names.add(entry.slot); + if (entry.matched !== Boolean(entry.computed)) { + context.addIssue({ + code: "custom", + message: `槽位 ${entry.slot} 的匹配状态与计算样式不一致`, + path: ["slots"] + }); + } + } + }); + +export type DocxThemeStyleSnapshot = z.infer< + typeof docxThemeStyleSnapshotSchema +>; diff --git a/packages/docx-theme-engine/src/tokens.ts b/packages/docx-theme-engine/src/tokens.ts new file mode 100644 index 0000000..dd6ecc0 --- /dev/null +++ b/packages/docx-theme-engine/src/tokens.ts @@ -0,0 +1,158 @@ +import { + docxStylePresetSchema, + docxThemeStyleModeSchema +} from "@md-to-pdf/core"; +import { z } from "zod"; +import { docxStyleSlotNameSchema } from "./slots.js"; + +const colorSchema = z + .string() + .regex(/^#[0-9a-f]{6}$/iu, "颜色必须使用六位十六进制格式"); + +export const docxStyleValueSourceSchema = z.enum([ + "computed-css", + "manifest-override", + "preset-fallback", + "engine-default" +]); + +export type DocxStyleValueSource = z.infer< + typeof docxStyleValueSourceSchema +>; + +export const docxStyleConfidenceSchema = z.enum([ + "exact", + "approximate", + "fallback" +]); + +export type DocxStyleConfidence = z.infer< + typeof docxStyleConfidenceSchema +>; + +export const docxThemeDiagnosticSeveritySchema = z.enum([ + "info", + "warning", + "error" +]); + +export const docxThemeDiagnosticCodeSchema = z.enum([ + "slot-not-found", + "css-value-invalid", + "css-property-unsupported", + "font-fallback-required", + "layout-approximated", + "preset-fallback-used", + "manifest-override-applied" +]); + +export const docxThemeDiagnosticSchema = z.object({ + severity: docxThemeDiagnosticSeveritySchema, + code: docxThemeDiagnosticCodeSchema, + message: z.string().min(1).max(500), + slot: docxStyleSlotNameSchema.optional(), + property: z.string().min(1).max(100).optional() +}); + +export type DocxThemeDiagnostic = z.infer< + typeof docxThemeDiagnosticSchema +>; + +const docxBorderTokenSchema = z.object({ + widthPt: z.number().min(0).max(20), + color: colorSchema, + style: z.enum([ + "none", + "single", + "double", + "dotted", + "dashed" + ]) +}); + +export const docxSlotStyleTokenSchema = z.object({ + fontCandidates: z.array(z.string().trim().min(1).max(100)).max(20), + fontSizePt: z.number().min(1).max(200).optional(), + bold: z.boolean().optional(), + italic: z.boolean().optional(), + underline: z.boolean().optional(), + strikethrough: z.boolean().optional(), + color: colorSchema.optional(), + backgroundColor: colorSchema.optional(), + lineSpacing: z.number().min(0.5).max(10).optional(), + letterSpacingPt: z.number().min(-20).max(100).optional(), + alignment: z + .enum(["left", "center", "right", "justify"]) + .optional(), + firstLineIndentPt: z.number().min(-1000).max(1000).optional(), + leftIndentPt: z.number().min(-1000).max(1000).optional(), + rightIndentPt: z.number().min(-1000).max(1000).optional(), + spacingBeforePt: z.number().min(0).max(2000).optional(), + spacingAfterPt: z.number().min(0).max(2000).optional(), + paddingPt: z + .object({ + top: z.number().min(0).max(1000), + right: z.number().min(0).max(1000), + bottom: z.number().min(0).max(1000), + left: z.number().min(0).max(1000) + }) + .optional(), + borders: z + .object({ + top: docxBorderTokenSchema.optional(), + right: docxBorderTokenSchema.optional(), + bottom: docxBorderTokenSchema.optional(), + left: docxBorderTokenSchema.optional() + }) + .optional(), + widthPercent: z.number().min(0).max(100).optional(), + pageBreakBefore: z.boolean().optional(), + pageBreakAfter: z.boolean().optional(), + keepLines: z.boolean().optional(), + keepWithNext: z.boolean().optional() +}); + +export type DocxSlotStyleToken = z.infer< + typeof docxSlotStyleTokenSchema +>; + +export const docxResolvedStyleSlotSchema = z.object({ + slot: docxStyleSlotNameSchema, + source: docxStyleValueSourceSchema, + confidence: docxStyleConfidenceSchema, + style: docxSlotStyleTokenSchema +}); + +export type DocxResolvedStyleSlot = z.infer< + typeof docxResolvedStyleSlotSchema +>; + +export const docxThemeTokenSetSchema = z + .object({ + schemaVersion: z.literal(1), + themeId: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u), + themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u), + mode: docxThemeStyleModeSchema, + basePreset: docxStylePresetSchema, + slots: z.array(docxResolvedStyleSlotSchema), + diagnostics: z.array(docxThemeDiagnosticSchema) + }) + .superRefine((tokens, context) => { + const names = new Set(); + for (const entry of tokens.slots) { + if (names.has(entry.slot)) { + context.addIssue({ + code: "custom", + message: `DOCX 样式令牌包含重复槽位:${entry.slot}`, + path: ["slots"] + }); + } + names.add(entry.slot); + } + }); + +export type DocxThemeTokenSet = z.infer< + typeof docxThemeTokenSetSchema +>; diff --git a/packages/docx-theme-engine/tests/contracts.test.ts b/packages/docx-theme-engine/tests/contracts.test.ts new file mode 100644 index 0000000..474fe7e --- /dev/null +++ b/packages/docx-theme-engine/tests/contracts.test.ts @@ -0,0 +1,162 @@ +import { themeManifestSchema } from "@md-to-pdf/core"; +import { describe, expect, it } from "vitest"; +import { + DOCX_STYLE_SLOTS, + docxThemeStyleSnapshotSchema, + docxThemeTokenSetSchema, + normalizeDocxThemeMappingConfig +} from "../src/index.js"; + +function createManifest(docxStyle?: unknown) { + return themeManifestSchema.parse({ + manifestVersion: 1, + id: "external-clean", + name: "External Clean", + version: "1.0.0", + description: "外部主题", + author: "Tester", + license: "MIT", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: ["table"], + category: "general", + compatibleProfiles: [], + ...(docxStyle ? { docxStyle } : {}), + bundled: false + }); +} + +describe("DOCX 主题映射配置", () => { + it("没有 DOCX 声明的外部主题默认使用自动映射", () => { + expect( + normalizeDocxThemeMappingConfig(createManifest()) + ).toEqual({ + mode: "auto", + basePreset: "general", + overrides: {}, + legacyPreset: false + }); + }); + + it("兼容现有 preset 与扁平覆盖", () => { + const result = normalizeDocxThemeMappingConfig( + createManifest({ + preset: "technical", + body: { sizePt: 11 } + }) + ); + expect(result).toMatchObject({ + mode: "explicit", + basePreset: "technical", + overrides: { body: { sizePt: 11 } }, + legacyPreset: true + }); + }); + + it("新式 overrides 优先于旧式扁平覆盖", () => { + const result = normalizeDocxThemeMappingConfig( + createManifest({ + mode: "auto-with-overrides", + basePreset: "formal", + body: { sizePt: 11, color: "#111111" }, + overrides: { body: { sizePt: 12 } } + }) + ); + expect(result.overrides.body?.sizePt).toBe(12); + expect(result.overrides.body?.color).toBe("#111111"); + }); +}); + +describe("DOCX 主题引擎契约", () => { + it("样式槽位名称与选择器保持唯一", () => { + expect(new Set(DOCX_STYLE_SLOTS.map((slot) => slot.name)).size) + .toBe(DOCX_STYLE_SLOTS.length); + expect( + new Set(DOCX_STYLE_SLOTS.map((slot) => slot.selector)).size + ).toBe(DOCX_STYLE_SLOTS.length); + }); + + it("拒绝重复或状态不一致的样式快照", () => { + const computed = { + fontFamily: "Arial", + fontSize: "16px", + fontWeight: "400", + fontStyle: "normal", + color: "rgb(0, 0, 0)", + backgroundColor: "rgba(0, 0, 0, 0)", + lineHeight: "24px", + letterSpacing: "normal", + textAlign: "start", + textIndent: "0px", + textDecorationLine: "none", + marginTop: "0px", + marginRight: "0px", + marginBottom: "0px", + marginLeft: "0px", + paddingTop: "0px", + paddingRight: "0px", + paddingBottom: "0px", + paddingLeft: "0px", + borderTop: "0px none rgb(0, 0, 0)", + borderRight: "0px none rgb(0, 0, 0)", + borderBottom: "0px none rgb(0, 0, 0)", + borderLeft: "0px none rgb(0, 0, 0)", + width: "100px", + maxWidth: "none", + breakBefore: "auto", + breakAfter: "auto", + breakInside: "auto", + display: "block" + }; + const result = docxThemeStyleSnapshotSchema.safeParse({ + schemaVersion: 1, + themeId: "external-clean", + themeFingerprint: "a".repeat(64), + viewport: { + widthPx: 794, + heightPx: 1123, + deviceScaleFactor: 1 + }, + rootFontSizePx: 16, + slots: [ + { slot: "paragraph", matched: true, computed }, + { slot: "paragraph", matched: false } + ] + }); + expect(result.success).toBe(false); + }); + + it("接受带来源、置信度和诊断的样式令牌", () => { + expect( + docxThemeTokenSetSchema.parse({ + schemaVersion: 1, + themeId: "external-clean", + themeFingerprint: "b".repeat(64), + mode: "auto", + basePreset: "general", + slots: [ + { + slot: "paragraph", + source: "computed-css", + confidence: "exact", + style: { + fontCandidates: ["Arial", "sans-serif"], + fontSizePt: 12, + color: "#111111" + } + } + ], + diagnostics: [ + { + severity: "warning", + code: "font-fallback-required", + message: "首选字体不可用", + slot: "paragraph", + property: "font-family" + } + ] + }).slots + ).toHaveLength(1); + }); +}); diff --git a/packages/docx-theme-engine/tsconfig.json b/packages/docx-theme-engine/tsconfig.json new file mode 100644 index 0000000..c6bd117 --- /dev/null +++ b/packages/docx-theme-engine/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src" + ] +}