feat: 建立 DOCX 主题映射引擎
This commit is contained in:
+11
-2
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
Generated
+15
@@ -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",
|
||||
|
||||
+5
-4
@@ -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"
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<ThemeManifest, "category">
|
||||
): DocxStylePreset {
|
||||
if (manifest.category === "red-letter") {
|
||||
return "official";
|
||||
}
|
||||
if (manifest.category === "formal") {
|
||||
return "formal";
|
||||
}
|
||||
if (manifest.category === "tender") {
|
||||
return "tender";
|
||||
}
|
||||
return "general";
|
||||
}
|
||||
|
||||
function mergeSection<T extends object>(
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./configuration.js";
|
||||
export * from "./slots.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./tokens.js";
|
||||
@@ -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<DocxStyleSlotName, DocxStyleSlotKind> = {
|
||||
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]));
|
||||
@@ -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<string>();
|
||||
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
|
||||
>;
|
||||
@@ -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<string>();
|
||||
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
|
||||
>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user