From 7f7225812636943dadd1d36a1b254701549d9642 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 22:57:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20DOCX=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E4=BB=A4=E7=89=8C=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/verify-docx-theme-styles.mjs | 77 +- .../tests/electron-docx-theme-style.test.ts | 10 +- .../scripts/verify-docx-theme-styles.mjs | 34 +- .../tests/playwright-docx-theme-style.test.ts | 10 +- docs/PROGRESS.md | 19 +- packages/docx-theme-engine/README.md | 12 +- packages/docx-theme-engine/src/collector.ts | 10 +- .../docx-theme-engine/src/configuration.ts | 2 +- packages/docx-theme-engine/src/css-values.ts | 214 +++++ packages/docx-theme-engine/src/index.ts | 2 + packages/docx-theme-engine/src/normalizer.ts | 818 ++++++++++++++++++ packages/docx-theme-engine/src/runtime.ts | 10 +- packages/docx-theme-engine/src/snapshot.ts | 10 +- packages/docx-theme-engine/src/tokens.ts | 5 + .../docx-theme-engine/tests/contracts.test.ts | 12 +- .../tests/normalizer.test.ts | 331 +++++++ .../docx-theme-engine/tests/probe.test.ts | 10 +- .../docx-theme-engine/tests/runtime.test.ts | 10 +- 18 files changed, 1571 insertions(+), 25 deletions(-) create mode 100644 packages/docx-theme-engine/src/css-values.ts create mode 100644 packages/docx-theme-engine/src/normalizer.ts create mode 100644 packages/docx-theme-engine/tests/normalizer.test.ts diff --git a/apps/desktop/scripts/verify-docx-theme-styles.mjs b/apps/desktop/scripts/verify-docx-theme-styles.mjs index 654f959..4251b09 100644 --- a/apps/desktop/scripts/verify-docx-theme-styles.mjs +++ b/apps/desktop/scripts/verify-docx-theme-styles.mjs @@ -7,7 +7,9 @@ import { DOCX_STYLE_SLOT_NAMES, createDocxThemeStyleCaptureScript, createDocxThemeStyleFingerprint, - parseDocxThemeStyleRuntimeCapture + normalizeDocxThemeMappingConfig, + parseDocxThemeStyleRuntimeCapture, + resolveDocxThemeTokens } from "@md-to-pdf/docx-theme-engine"; import { app, BrowserWindow } from "electron"; @@ -113,8 +115,9 @@ try { .filter((theme) => theme.source === "bundled") .sort((first, second) => first.id.localeCompare(second.id, "en") - ); + ); const snapshots = []; + const tokenSets = []; for (const theme of bundledThemes) { console.error(`[Electron DOCX theme styles] capturing ${theme.id}`); const themeCss = await application.getThemeCss(theme.id); @@ -144,11 +147,67 @@ try { `主题 ${theme.id} 存在未命中槽位` ); snapshots.push(snapshot); + tokenSets.push( + resolveDocxThemeTokens({ + snapshot, + config: normalizeDocxThemeMappingConfig(theme) + }) + ); } assert( snapshots.length === 14, `内置主题数量应为 14,实际为 ${snapshots.length}` ); + const invalidDiagnostics = tokenSets.flatMap((tokens) => + tokens.diagnostics.filter( + (diagnostic) => + diagnostic.severity === "error" || + diagnostic.code === "css-value-invalid" + ) + ); + assert( + invalidDiagnostics.length === 0, + `DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断` + ); + const playwrightReport = JSON.parse( + fs.readFileSync( + path.join(outputDirectory, "snapshots.json"), + "utf8" + ) + ); + const playwrightTokensByTheme = new Map( + playwrightReport.tokenSets.map((tokens) => [ + tokens.themeId, + tokens + ]) + ); + const crossEngineMismatches = []; + for (const electronTokens of tokenSets) { + const playwrightTokens = playwrightTokensByTheme.get( + electronTokens.themeId + ); + assert( + playwrightTokens, + `Playwright 报告缺少主题 ${electronTokens.themeId}` + ); + for (const electronSlot of electronTokens.slots) { + const playwrightSlot = playwrightTokens.slots.find( + (slot) => slot.slot === electronSlot.slot + ); + if ( + JSON.stringify(playwrightSlot) !== + JSON.stringify(electronSlot) + ) { + crossEngineMismatches.push( + `${electronTokens.themeId}:${electronSlot.slot}` + ); + } + } + } + assert( + crossEngineMismatches.length === 0, + `Playwright/Electron 令牌不一致:${crossEngineMismatches.join("、")}` + ); fs.mkdirSync(outputDirectory, { recursive: true }); fs.writeFileSync( path.join(outputDirectory, "electron-snapshots.json"), @@ -159,7 +218,10 @@ try { chromiumVersion: process.versions.chrome, themeCount: snapshots.length, slotCount: DOCX_STYLE_SLOT_NAMES.length, - snapshots + crossEngineTokenMismatches: + crossEngineMismatches.length, + snapshots, + tokenSets }, null, 2 @@ -173,7 +235,14 @@ try { themes: snapshots.length, slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length, totalSlots: - snapshots.length * DOCX_STYLE_SLOT_NAMES.length + snapshots.length * DOCX_STYLE_SLOT_NAMES.length, + tokenSets: tokenSets.length, + crossEngineTokenMismatches: + crossEngineMismatches.length, + diagnostics: tokenSets.reduce( + (count, tokens) => count + tokens.diagnostics.length, + 0 + ) }) ); } finally { diff --git a/apps/desktop/tests/electron-docx-theme-style.test.ts b/apps/desktop/tests/electron-docx-theme-style.test.ts index 1068b04..c4d2be5 100644 --- a/apps/desktop/tests/electron-docx-theme-style.test.ts +++ b/apps/desktop/tests/electron-docx-theme-style.test.ts @@ -16,6 +16,7 @@ const computed: DocxComputedStyle = { lineHeight: "normal", letterSpacing: "normal", textAlign: "start", + direction: "ltr", textIndent: "0px", textDecorationLine: "none", marginTop: "0px", @@ -32,10 +33,17 @@ const computed: DocxComputedStyle = { borderLeft: "0px none rgb(0, 0, 0)", width: "640px", maxWidth: "none", + minHeight: "0px", + height: "24px", breakBefore: "auto", breakAfter: "auto", breakInside: "auto", - display: "block" + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "rgb(0, 0, 0) none 0px", + outlineOffset: "0px" }; const request: DocxThemeStyleCaptureRequest = { diff --git a/apps/server/scripts/verify-docx-theme-styles.mjs b/apps/server/scripts/verify-docx-theme-styles.mjs index cabfc6a..3d2c2f6 100644 --- a/apps/server/scripts/verify-docx-theme-styles.mjs +++ b/apps/server/scripts/verify-docx-theme-styles.mjs @@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url"; import { createApplicationService } from "@md-to-pdf/application"; import { DOCX_STYLE_SLOT_NAMES, - createDocxThemeStyleFingerprint + createDocxThemeStyleFingerprint, + normalizeDocxThemeMappingConfig, + resolveDocxThemeTokens } from "@md-to-pdf/docx-theme-engine"; import { chromium } from "playwright"; import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js"; @@ -99,8 +101,9 @@ try { .filter((theme) => theme.source === "bundled") .sort((first, second) => first.id.localeCompare(second.id, "en") - ); + ); const snapshots = []; + const tokenSets = []; for (const theme of bundledThemes) { console.error(`[DOCX theme styles] capturing ${theme.id}`); const themeCss = await application.getThemeCss(theme.id); @@ -126,11 +129,28 @@ try { .join("、")}` ); snapshots.push(snapshot); + tokenSets.push( + resolveDocxThemeTokens({ + snapshot, + config: normalizeDocxThemeMappingConfig(theme) + }) + ); } assert( snapshots.length === 14, `内置主题数量应为 14,实际为 ${snapshots.length}` ); + const invalidDiagnostics = tokenSets.flatMap((tokens) => + tokens.diagnostics.filter( + (diagnostic) => + diagnostic.severity === "error" || + diagnostic.code === "css-value-invalid" + ) + ); + assert( + invalidDiagnostics.length === 0, + `DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断` + ); const paragraphStyles = new Set( snapshots.map( (snapshot) => @@ -151,7 +171,8 @@ try { chromiumVersion: browser.version(), themeCount: snapshots.length, slotCount: DOCX_STYLE_SLOT_NAMES.length, - snapshots + snapshots, + tokenSets }, null, 2 @@ -164,7 +185,12 @@ try { themes: snapshots.length, slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length, totalSlots: - snapshots.length * DOCX_STYLE_SLOT_NAMES.length + snapshots.length * DOCX_STYLE_SLOT_NAMES.length, + tokenSets: tokenSets.length, + diagnostics: tokenSets.reduce( + (count, tokens) => count + tokens.diagnostics.length, + 0 + ) }) ); await context.close(); diff --git a/apps/server/tests/playwright-docx-theme-style.test.ts b/apps/server/tests/playwright-docx-theme-style.test.ts index 760bff8..ffdc21c 100644 --- a/apps/server/tests/playwright-docx-theme-style.test.ts +++ b/apps/server/tests/playwright-docx-theme-style.test.ts @@ -16,6 +16,7 @@ const computed: DocxComputedStyle = { lineHeight: "normal", letterSpacing: "normal", textAlign: "start", + direction: "ltr", textIndent: "0px", textDecorationLine: "none", marginTop: "0px", @@ -32,10 +33,17 @@ const computed: DocxComputedStyle = { borderLeft: "0px none rgb(0, 0, 0)", width: "640px", maxWidth: "none", + minHeight: "0px", + height: "24px", breakBefore: "auto", breakAfter: "auto", breakInside: "auto", - display: "block" + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "rgb(0, 0, 0) none 0px", + outlineOffset: "0px" }; const request: DocxThemeStyleCaptureRequest = { diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ca0be01..e0afc43 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -168,8 +168,20 @@ DOCX 映射。 Electron Chromium 150 中采集 56 个槽位,共检查两组各 784 个槽位; 两端逐槽位 `font-family`、`font-size` 和 `color` 差异为 0。统一复现 命令为 `npm run verify:docx-theme-styles`,结果写入被 Git 忽略的 -`output/docx-theme-styles/`。当前尚未将快照归一化为 Word 令牌或接入 -Pandoc 转换链路。 +`output/docx-theme-styles/`。 + +阶段 11 的计算样式归一化已经完成:通用解析器支持浏览器长度到 Word +磅值、RGB/RGBA/十六进制颜色、带引号字体候选、粗斜体、上下划线、行距、 +字符间距、段落缩进与间距、四边内边距和边框、百分比宽度、分页控制; +结构令牌额外保留封面最小高度、Flex 纵向对齐、CSS outline 四边框近似 +以及 `display:none` 隐藏字段。旧版仅声明 `docxStyle.preset` 的内置主题 +自动升级为 `auto-with-overrides`,因此主题 CSS 成为主来源,清单覆盖 +优先,原预设只在槽位缺失时降级;真正的 `explicit` 模式仍完全跳过 CSS。 +Playwright Chromium 151 与 Electron Chromium 150 分别对 14 套主题生成 +14 组、每组 56 槽位的 Word 令牌,跨引擎令牌差异为 0,无无效 CSS 诊断。 +当前 14 条有效诊断均为 Flex/Grid 结构近似、商务标书超粗装饰边收敛或 +经典标书双层边框与轮廓偏移近似,将在下一阶段由语义文档结构层消费。当前尚未将令牌接入 +`reference.docx` 或 Pandoc 转换链路。 ## 2. 已完成 @@ -1098,7 +1110,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及 快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器; Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过; - 下一步实现 CSS 计算值到 Word 样式令牌的归一化; + CSS 计算值到 Word 样式令牌的通用归一化、清单覆盖、预设降级和结构 + 近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌; - 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、 表格宽度和分页控制; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 diff --git a/packages/docx-theme-engine/README.md b/packages/docx-theme-engine/README.md index ef6c781..96bd541 100644 --- a/packages/docx-theme-engine/README.md +++ b/packages/docx-theme-engine/README.md @@ -11,10 +11,13 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立 - 不绑定具体浏览器实现的计算样式采集器; - 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本; - 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存; -- Word 目标样式令牌; +- CSS 长度、颜色、字体、边框和布局值的通用解析器; +- 56 个语义槽位到 Word 文本、段落、表格、媒体和结构令牌的归一化; +- 封面最小高度、垂直对齐、轮廓线和隐藏字段语义; - 自动映射、显式覆盖和预设降级配置; - 样式来源、映射置信度和诊断协议; -- 旧版 `docxStyle.preset` 兼容归一化。 +- 旧版 `docxStyle.preset` 兼容归一化,并自动升级为 + `auto-with-overrides`。 本包不负责: @@ -26,5 +29,6 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立 Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期 中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。 -本包下一步负责将快照归一化为 DOCX 样式令牌, -`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。 +`@md-to-pdf/docx-engine` 后续消费本包令牌生成 `reference.docx` 和最终 +OOXML。Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似 +令牌与诊断,供语义文档结构层选择 Word 表格、分节或段落布局实现。 diff --git a/packages/docx-theme-engine/src/collector.ts b/packages/docx-theme-engine/src/collector.ts index 1cdbfcd..d656142 100644 --- a/packages/docx-theme-engine/src/collector.ts +++ b/packages/docx-theme-engine/src/collector.ts @@ -44,6 +44,7 @@ export function readDocxComputedStyle( lineHeight: style.lineHeight, letterSpacing: style.letterSpacing, textAlign: style.textAlign, + direction: style.direction, textIndent: style.textIndent, textDecorationLine: style.textDecorationLine, marginTop: style.marginTop, @@ -60,10 +61,17 @@ export function readDocxComputedStyle( borderLeft: style.borderLeft, width: style.width, maxWidth: style.maxWidth, + minHeight: style.minHeight, + height: style.height, breakBefore: style.breakBefore, breakAfter: style.breakAfter, breakInside: style.breakInside, - display: style.display + display: style.display, + flexDirection: style.flexDirection, + alignItems: style.alignItems, + justifyContent: style.justifyContent, + outline: style.outline, + outlineOffset: style.outlineOffset }; } diff --git a/packages/docx-theme-engine/src/configuration.ts b/packages/docx-theme-engine/src/configuration.ts index fd71aeb..7d3950a 100644 --- a/packages/docx-theme-engine/src/configuration.ts +++ b/packages/docx-theme-engine/src/configuration.ts @@ -51,7 +51,7 @@ export function normalizeDocxThemeMappingConfig( style.basePreset === undefined; const mode = style?.mode ?? - (legacyPreset ? "explicit" : "auto"); + (legacyPreset ? "auto-with-overrides" : "auto"); const basePreset = style?.basePreset ?? style?.preset ?? diff --git a/packages/docx-theme-engine/src/css-values.ts b/packages/docx-theme-engine/src/css-values.ts new file mode 100644 index 0000000..ba460d0 --- /dev/null +++ b/packages/docx-theme-engine/src/css-values.ts @@ -0,0 +1,214 @@ +const PX_TO_PT = 72 / 96; +const MM_TO_PT = 72 / 25.4; + +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} + +export function parseCssLengthToPt( + input: string +): number | undefined { + const value = input.trim().toLowerCase(); + if (value === "0") { + return 0; + } + const match = /^(-?(?:\d+|\d*\.\d+))(px|pt|pc|in|cm|mm|q)$/u.exec( + value + ); + if (!match) { + return undefined; + } + const amount = Number(match[1]); + if (!Number.isFinite(amount)) { + return undefined; + } + const unit = match[2]; + const factors: Readonly> = { + px: PX_TO_PT, + pt: 1, + pc: 12, + in: 72, + cm: 72 / 2.54, + mm: MM_TO_PT, + q: MM_TO_PT / 4 + }; + const factor = unit ? factors[unit] : undefined; + return factor === undefined ? undefined : round(amount * factor); +} + +export function parseCssColor( + input: string +): string | undefined { + const value = input.trim().toLowerCase(); + if (value === "transparent") { + return undefined; + } + const hex = /^#([\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/u.exec(value); + if (hex?.[1]) { + if (hex[1].length === 3) { + return `#${[...hex[1]].map((part) => part.repeat(2)).join("")}`; + } + if (hex[1].length === 8) { + const alpha = Number.parseInt(hex[1].slice(6), 16) / 255; + if (alpha === 0) { + return undefined; + } + const channels = [0, 2, 4].map((offset) => + Number.parseInt(hex[1]?.slice(offset, offset + 2) ?? "0", 16) + ); + return formatRgb(compositeOnWhite(channels, alpha)); + } + return `#${hex[1].slice(0, 6)}`; + } + const rgb = + /^rgba?\(\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*(\d+(?:\.\d+)?%?))?\s*\)$/u.exec( + value + ); + if (!rgb) { + return undefined; + } + const alpha = parseAlpha(rgb[4]); + if (alpha === 0) { + return undefined; + } + const channels = rgb.slice(1, 4).map((part) => + Math.max(0, Math.min(255, Math.round(Number(part)))) + ); + return formatRgb( + alpha === undefined ? channels : compositeOnWhite(channels, alpha) + ); +} + +function parseAlpha(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const alpha = value.endsWith("%") + ? Number(value.slice(0, -1)) / 100 + : Number(value); + return Number.isFinite(alpha) + ? Math.max(0, Math.min(1, alpha)) + : undefined; +} + +function compositeOnWhite( + channels: readonly number[], + alpha: number +): number[] { + return channels.map((channel) => + Math.round(channel * alpha + 255 * (1 - alpha)) + ); +} + +function formatRgb(channels: readonly number[]): string { + return `#${channels + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("")}`; +} + +export function cssColorHasPartialAlpha(input: string): boolean { + const value = input.trim().toLowerCase(); + const hex = /^#[\da-f]{6}([\da-f]{2})$/u.exec(value); + if (hex?.[1]) { + const alpha = Number.parseInt(hex[1], 16); + return alpha > 0 && alpha < 255; + } + const rgb = + /^rgba?\([^)]*(?:[,/]\s*(\d+(?:\.\d+)?%?))\s*\)$/u.exec(value); + const alpha = parseAlpha(rgb?.[1]); + return alpha !== undefined && alpha > 0 && alpha < 1; +} + +export function parseCssFontFamilies(input: string): string[] { + const families: string[] = []; + let current = ""; + let quote = ""; + for (const character of input) { + if ((character === `"` || character === "'") && !quote) { + quote = character; + continue; + } + if (character === quote) { + quote = ""; + continue; + } + if (character === "," && !quote) { + const family = current.trim(); + if (family) { + families.push(family); + } + current = ""; + continue; + } + current += character; + } + const finalFamily = current.trim(); + if (finalFamily) { + families.push(finalFamily); + } + return [...new Set(families)].slice(0, 20); +} + +export interface ParsedCssBorder { + widthPt: number; + color: string; + style: "none" | "single" | "double" | "dotted" | "dashed"; + approximated: boolean; +} + +export function parseCssBorder( + input: string +): ParsedCssBorder | undefined { + const value = input.trim(); + const widthMatch = + /(?:^|\s)(-?(?:\d+|\d*\.\d+)(?:px|pt|pc|in|cm|mm|q))(?=\s|$)/iu.exec( + value + ); + const styleMatch = + /\b(none|hidden|solid|double|dotted|dashed|groove|ridge|inset|outset)\b/iu.exec( + value + ); + const colorMatch = + /(rgba?\([^)]*\)|#[\da-f]{3,8})/iu.exec(value); + if (!widthMatch?.[1] || !styleMatch?.[1] || !colorMatch?.[1]) { + return undefined; + } + const widthPt = parseCssLengthToPt(widthMatch[1]); + const color = parseCssColor(colorMatch[1]); + if (widthPt === undefined) { + return undefined; + } + const cssStyle = styleMatch[1].toLowerCase(); + if (widthPt <= 0 || cssStyle === "none" || cssStyle === "hidden") { + return { + widthPt: 0, + color: color ?? "#000000", + style: "none", + approximated: false + }; + } + if (!color) { + return undefined; + } + const supported = { + solid: "single", + double: "double", + dotted: "dotted", + dashed: "dashed" + } as const; + const style = supported[cssStyle as keyof typeof supported]; + return { + widthPt, + color, + style: style ?? "single", + approximated: style === undefined + }; +} + +export function millimetersToPoints(value: number): number { + return round(value * MM_TO_PT); +} + +export function roundDocxValue(value: number): number { + return round(value); +} diff --git a/packages/docx-theme-engine/src/index.ts b/packages/docx-theme-engine/src/index.ts index a87e214..0ef9c62 100644 --- a/packages/docx-theme-engine/src/index.ts +++ b/packages/docx-theme-engine/src/index.ts @@ -1,6 +1,8 @@ export * from "./collector.js"; export * from "./configuration.js"; +export * from "./css-values.js"; export * from "./fingerprint.js"; +export * from "./normalizer.js"; export * from "./probe.js"; export * from "./runtime.js"; export * from "./snapshot-cache.js"; diff --git a/packages/docx-theme-engine/src/normalizer.ts b/packages/docx-theme-engine/src/normalizer.ts new file mode 100644 index 0000000..313e7e0 --- /dev/null +++ b/packages/docx-theme-engine/src/normalizer.ts @@ -0,0 +1,818 @@ +import type { + DocxFontFamily, + DocxThemeStyleOverrides +} from "@md-to-pdf/core"; +import { + cssColorHasPartialAlpha, + millimetersToPoints, + parseCssBorder, + parseCssColor, + parseCssFontFamilies, + parseCssLengthToPt, + roundDocxValue +} from "./css-values.js"; +import type { + NormalizedDocxThemeMappingConfig +} from "./configuration.js"; +import { + DOCX_STYLE_SLOTS, + type DocxStyleSlotKind, + type DocxStyleSlotName +} from "./slots.js"; +import { + docxThemeStyleSnapshotSchema, + type DocxComputedStyle, + type DocxThemeStyleSnapshot +} from "./snapshot.js"; +import { + docxThemeTokenSetSchema, + type DocxResolvedStyleSlot, + type DocxSlotStyleToken, + type DocxStyleConfidence, + type DocxThemeDiagnostic, + type DocxThemeTokenSet +} from "./tokens.js"; + +type MutableStyle = { + -readonly [Property in keyof DocxSlotStyleToken]: + DocxSlotStyleToken[Property]; +}; + +interface SlotNormalizationContext { + slot: DocxStyleSlotName; + kind: DocxStyleSlotKind; + documentWidthPx?: number; + diagnostics: DocxThemeDiagnostic[]; + approximate: boolean; +} + +const paragraphKinds = new Set([ + "document", + "paragraph", + "list", + "structure" +]); +const widthSlots = new Set([ + "table", + "figure" +]); +const bodyOverrideSlots = new Set([ + "document", + "document-author", + "paragraph", + "unordered-list", + "ordered-list", + "list-item", + "footnotes" +]); +const headingSlots = [ + "heading-1", + "heading-2", + "heading-3", + "heading-4", + "heading-5", + "heading-6" +] as const satisfies readonly DocxStyleSlotName[]; + +function diagnostic( + context: SlotNormalizationContext, + input: Omit +): void { + context.diagnostics.push({ + ...input, + slot: context.slot + }); +} + +function invalidCssValue( + context: SlotNormalizationContext, + property: string, + value: string +): void { + diagnostic(context, { + severity: "warning", + code: "css-value-invalid", + property, + message: `无法将 CSS ${property} 值“${value}”映射到 Word` + }); + context.approximate = true; +} + +function length( + context: SlotNormalizationContext, + property: string, + value: string, + options: { allowAuto?: boolean; nonNegative?: boolean } = {} +): number | undefined { + const normalized = value.trim().toLowerCase(); + if ( + options.allowAuto && + ["auto", "none", "normal"].includes(normalized) + ) { + return undefined; + } + const result = parseCssLengthToPt(value); + if (result === undefined) { + invalidCssValue(context, property, value); + return undefined; + } + if (options.nonNegative && result < 0) { + diagnostic(context, { + severity: "warning", + code: "layout-approximated", + property, + message: `Word 不支持负的 ${property},已收敛为 0` + }); + context.approximate = true; + return 0; + } + return result; +} + +function color( + context: SlotNormalizationContext, + property: string, + value: string, + transparentAllowed = false +): string | undefined { + const result = parseCssColor(value); + if ( + result === undefined && + !( + transparentAllowed && + /^(?:transparent|rgba?\([^)]*(?:,\s*0|\/\s*0%?)\s*\))$/iu.test( + value.trim() + ) + ) + ) { + invalidCssValue(context, property, value); + } + if (result !== undefined && cssColorHasPartialAlpha(value)) { + diagnostic(context, { + severity: "info", + code: "layout-approximated", + property, + message: `CSS ${property} 半透明色已按白底合成为 Word 不透明色` + }); + context.approximate = true; + } + return result; +} + +function normalizeAlignment( + context: SlotNormalizationContext, + computed: DocxComputedStyle +): DocxSlotStyleToken["alignment"] { + const alignment = computed.textAlign.toLowerCase(); + if ( + alignment === "left" || + alignment === "center" || + alignment === "right" || + alignment === "justify" + ) { + return alignment; + } + if (alignment === "start") { + return computed.direction === "rtl" ? "right" : "left"; + } + if (alignment === "end") { + return computed.direction === "rtl" ? "left" : "right"; + } + invalidCssValue(context, "text-align", computed.textAlign); + return undefined; +} + +function normalizeBorder( + context: SlotNormalizationContext, + property: string, + value: string +) { + const parsed = parseCssBorder(value); + if (!parsed) { + invalidCssValue(context, property, value); + return undefined; + } + if (parsed.approximated) { + diagnostic(context, { + severity: "warning", + code: "css-property-unsupported", + property, + message: `CSS ${property} 的边框样式已近似为 Word 单实线` + }); + context.approximate = true; + } + const widthPt = Math.min(20, parsed.widthPt); + if (widthPt !== parsed.widthPt) { + diagnostic(context, { + severity: "warning", + code: "layout-approximated", + property, + message: `CSS ${property} 宽度 ${parsed.widthPt}pt 超过 Word 映射上限,已收敛为 20pt` + }); + context.approximate = true; + } + return parsed.style === "none" ? undefined : { + widthPt, + color: parsed.color, + style: parsed.style + }; +} + +function normalizeComputedSlot( + computed: DocxComputedStyle, + context: SlotNormalizationContext +): { + style: DocxSlotStyleToken; + confidence: DocxStyleConfidence; +} { + const fontSizePt = length( + context, + "font-size", + computed.fontSize + ); + const style: MutableStyle = { + fontCandidates: parseCssFontFamilies(computed.fontFamily) + }; + if (!style.fontCandidates.length) { + invalidCssValue(context, "font-family", computed.fontFamily); + } + if (fontSizePt !== undefined) { + style.fontSizePt = fontSizePt; + } + const weight = Number.parseInt(computed.fontWeight, 10); + style.bold = + computed.fontWeight.toLowerCase() === "bold" || + (Number.isFinite(weight) && weight >= 600); + style.italic = ["italic", "oblique"].includes( + computed.fontStyle.toLowerCase() + ); + const decorations = computed.textDecorationLine + .toLowerCase() + .split(/\s+/u); + style.underline = decorations.includes("underline"); + style.strikethrough = decorations.includes("line-through"); + const foreground = color(context, "color", computed.color); + if (foreground) { + style.color = foreground; + } + const background = color( + context, + "background-color", + computed.backgroundColor, + true + ); + if (background) { + style.backgroundColor = background; + } + const letterSpacing = computed.letterSpacing.toLowerCase() === "normal" + ? 0 + : length(context, "letter-spacing", computed.letterSpacing); + if (letterSpacing !== undefined) { + style.letterSpacingPt = letterSpacing; + } + if (fontSizePt !== undefined) { + if (computed.lineHeight.toLowerCase() === "normal") { + style.lineSpacing = 1.2; + diagnostic(context, { + severity: "info", + code: "layout-approximated", + property: "line-height", + message: "CSS normal 行高按 Word 1.2 倍行距近似" + }); + context.approximate = true; + } else { + const lineHeightPt = length( + context, + "line-height", + computed.lineHeight + ); + if (lineHeightPt !== undefined) { + style.lineSpacing = roundDocxValue( + Math.max(0.5, Math.min(10, lineHeightPt / fontSizePt)) + ); + } + } + } + const top = normalizeBorder(context, "border-top", computed.borderTop); + const right = normalizeBorder( + context, + "border-right", + computed.borderRight + ); + const bottom = normalizeBorder( + context, + "border-bottom", + computed.borderBottom + ); + const left = normalizeBorder( + context, + "border-left", + computed.borderLeft + ); + if (top || right || bottom || left) { + style.borders = { + ...(top ? { top } : {}), + ...(right ? { right } : {}), + ...(bottom ? { bottom } : {}), + ...(left ? { left } : {}) + }; + } + const outline = normalizeBorder( + context, + "outline", + computed.outline + ); + if (outline) { + if (!style.borders) { + style.borders = { + top: outline, + right: outline, + bottom: outline, + left: outline + }; + diagnostic(context, { + severity: "warning", + code: "layout-approximated", + property: "outline", + message: "CSS outline 已近似为 Word 四边边框" + }); + } else { + diagnostic(context, { + severity: "warning", + code: "css-property-unsupported", + property: "outline", + message: "CSS border 与 outline 并存,Word 单层边框仅保留 border,结构层需生成双层边框" + }); + } + context.approximate = true; + const outlineOffsetPt = length( + context, + "outline-offset", + computed.outlineOffset + ); + if (outlineOffsetPt !== undefined && outlineOffsetPt !== 0) { + diagnostic(context, { + severity: "warning", + code: "css-property-unsupported", + property: "outline-offset", + message: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理` + }); + context.approximate = true; + } + } + if (paragraphKinds.has(context.kind) || context.kind === "table") { + style.alignment = normalizeAlignment(context, computed); + const firstLineIndentPt = length( + context, + "text-indent", + computed.textIndent + ); + const leftIndentPt = length( + context, + "margin-left", + computed.marginLeft + ); + const rightIndentPt = length( + context, + "margin-right", + computed.marginRight + ); + const spacingBeforePt = length( + context, + "margin-top", + computed.marginTop, + { nonNegative: true } + ); + const spacingAfterPt = length( + context, + "margin-bottom", + computed.marginBottom, + { nonNegative: true } + ); + if (firstLineIndentPt !== undefined) { + style.firstLineIndentPt = firstLineIndentPt; + } + if (leftIndentPt !== undefined) { + style.leftIndentPt = leftIndentPt; + } + if (rightIndentPt !== undefined) { + style.rightIndentPt = rightIndentPt; + } + if (spacingBeforePt !== undefined) { + style.spacingBeforePt = spacingBeforePt; + } + if (spacingAfterPt !== undefined) { + style.spacingAfterPt = spacingAfterPt; + } + } + if (context.kind !== "inline") { + const padding = { + top: length(context, "padding-top", computed.paddingTop, { + nonNegative: true + }), + right: length(context, "padding-right", computed.paddingRight, { + nonNegative: true + }), + bottom: length(context, "padding-bottom", computed.paddingBottom, { + nonNegative: true + }), + left: length(context, "padding-left", computed.paddingLeft, { + nonNegative: true + }) + }; + if ( + padding.top !== undefined && + padding.right !== undefined && + padding.bottom !== undefined && + padding.left !== undefined + ) { + style.paddingPt = { + top: padding.top, + right: padding.right, + bottom: padding.bottom, + left: padding.left + }; + } + } + if (widthSlots.has(context.slot) && context.documentWidthPx) { + const widthPt = parseCssLengthToPt(computed.width); + const widthPx = widthPt === undefined ? undefined : widthPt / 0.75; + if (widthPx !== undefined) { + const percent = (widthPx / context.documentWidthPx) * 100; + style.widthPercent = Math.round( + Math.max(0, Math.min(100, percent)) + ); + if (percent > 100.01) { + diagnostic(context, { + severity: "info", + code: "layout-approximated", + property: "width", + message: "元素宽度超过正文区域,已收敛为 100%" + }); + context.approximate = true; + } + } + } + const minimumHeightPt = length( + context, + "min-height", + computed.minHeight, + { allowAuto: true, nonNegative: true } + ); + if (minimumHeightPt !== undefined && minimumHeightPt > 0) { + style.minimumHeightPt = minimumHeightPt; + } + if (computed.display === "none") { + style.hidden = true; + } + if ( + computed.display === "flex" && + computed.flexDirection.startsWith("column") + ) { + const verticalAlignment = { + "flex-start": "top", + start: "top", + center: "center", + "flex-end": "bottom", + end: "bottom" + } as const; + const resolved = + verticalAlignment[ + computed.justifyContent as keyof typeof verticalAlignment + ]; + if (resolved) { + style.verticalAlignment = resolved; + } + } + if (computed.display === "flex" || computed.display === "grid") { + diagnostic(context, { + severity: "info", + code: "layout-approximated", + property: "display", + message: `CSS ${computed.display} 布局将由 DOCX 结构层近似` + }); + context.approximate = true; + } + style.pageBreakBefore = ["page", "left", "right"].includes( + computed.breakBefore + ) + ? true + : undefined; + style.pageBreakAfter = ["page", "left", "right"].includes( + computed.breakAfter + ) + ? true + : undefined; + style.keepLines = ["avoid", "avoid-page"].includes( + computed.breakInside + ) + ? true + : undefined; + style.keepWithNext = ["avoid", "avoid-page"].includes( + computed.breakAfter + ) + ? true + : undefined; + return { + style, + confidence: context.approximate ? "approximate" : "exact" + }; +} + +function overrideFontCandidates(fonts: DocxFontFamily): string[] { + return [ + fonts.eastAsia, + fonts.latin, + ...(fonts.complexScript ? [fonts.complexScript] : []) + ].filter((font, index, all) => all.indexOf(font) === index); +} + +function applyBorderColor( + style: MutableStyle, + borderColor: string, + sides: readonly ("top" | "right" | "bottom" | "left")[] +): void { + const borders = { ...style.borders }; + for (const side of sides) { + borders[side] = { + widthPt: borders[side]?.widthPt ?? 0.75, + style: borders[side]?.style ?? "single", + color: borderColor + }; + } + style.borders = borders; +} + +function applyOverrides( + slot: DocxStyleSlotName, + styleInput: DocxSlotStyleToken, + overrides: DocxThemeStyleOverrides +): { + style: DocxSlotStyleToken; + applied: boolean; +} { + const style: MutableStyle = { ...styleInput }; + let applied = false; + const assign = ( + key: Key, + value: MutableStyle[Key] | undefined + ) => { + if (value !== undefined) { + style[key] = value; + applied = true; + } + }; + if (bodyOverrideSlots.has(slot) && overrides.body) { + const body = overrides.body; + assign( + "fontCandidates", + body.fonts + ? overrideFontCandidates(body.fonts) + : undefined + ); + assign("fontSizePt", body.sizePt); + assign("color", body.color?.toLowerCase()); + assign("lineSpacing", body.lineSpacing); + assign( + "firstLineIndentPt", + body.firstLineIndentChars === undefined + ? undefined + : body.firstLineIndentChars * + (body.sizePt ?? style.fontSizePt ?? 12) + ); + assign("spacingBeforePt", body.spacingBeforePt); + assign("spacingAfterPt", body.spacingAfterPt); + } + const headingIndex = headingSlots.indexOf( + slot as (typeof headingSlots)[number] + ); + if (headingIndex >= 0 && overrides.headings) { + const headings = overrides.headings; + assign( + "fontCandidates", + headings.fonts + ? overrideFontCandidates(headings.fonts) + : undefined + ); + assign("fontSizePt", headings.sizesPt?.[headingIndex]); + assign("color", headings.color?.toLowerCase()); + assign("bold", headings.bold); + assign("spacingBeforePt", headings.spacingBeforePt); + assign("spacingAfterPt", headings.spacingAfterPt); + } + if ( + (slot === "inline-code" || slot === "code-block") && + overrides.code + ) { + const code = overrides.code; + assign( + "fontCandidates", + code.fonts ? overrideFontCandidates(code.fonts) : undefined + ); + assign("fontSizePt", code.sizePt); + assign("color", code.color?.toLowerCase()); + assign("backgroundColor", code.backgroundColor?.toLowerCase()); + assign("lineSpacing", code.lineSpacing); + if (code.borderColor) { + applyBorderColor( + style, + code.borderColor.toLowerCase(), + ["top", "right", "bottom", "left"] + ); + applied = true; + } + } + if (slot === "block-quote" && overrides.blockQuote) { + const quote = overrides.blockQuote; + assign("color", quote.color?.toLowerCase()); + assign("backgroundColor", quote.backgroundColor?.toLowerCase()); + assign("italic", quote.italic); + assign( + "leftIndentPt", + quote.leftIndentChars === undefined + ? undefined + : quote.leftIndentChars * (style.fontSizePt ?? 12) + ); + if (quote.borderColor) { + applyBorderColor( + style, + quote.borderColor.toLowerCase(), + ["left"] + ); + applied = true; + } + } + if ( + (slot === "table" || + slot === "table-header" || + slot === "table-cell") && + overrides.table + ) { + const table = overrides.table; + assign( + "fontCandidates", + table.fonts ? overrideFontCandidates(table.fonts) : undefined + ); + assign("fontSizePt", table.sizePt); + assign( + "color", + ( + slot === "table-header" + ? table.headerColor ?? table.color + : table.color + )?.toLowerCase() + ); + if (slot === "table-header") { + assign( + "backgroundColor", + table.headerBackgroundColor?.toLowerCase() + ); + } + if (table.borderColor) { + applyBorderColor( + style, + table.borderColor.toLowerCase(), + ["top", "right", "bottom", "left"] + ); + applied = true; + } + if ( + table.cellMarginMm !== undefined && + (slot === "table-header" || slot === "table-cell") + ) { + const margin = millimetersToPoints(table.cellMarginMm); + assign("paddingPt", { + top: margin, + right: margin, + bottom: margin, + left: margin + }); + } + } + if (slot === "caption" && overrides.caption) { + const caption = overrides.caption; + assign( + "fontCandidates", + caption.fonts + ? overrideFontCandidates(caption.fonts) + : undefined + ); + assign("fontSizePt", caption.sizePt); + assign("color", caption.color?.toLowerCase()); + assign("italic", caption.italic); + assign("alignment", caption.alignment); + } + if (slot === "hyperlink" && overrides.hyperlink) { + assign("color", overrides.hyperlink.color?.toLowerCase()); + assign("underline", overrides.hyperlink.underline); + } + return { style, applied }; +} + +export interface ResolveDocxThemeTokensOptions { + snapshot: DocxThemeStyleSnapshot; + config: NormalizedDocxThemeMappingConfig; +} + +export function resolveDocxThemeTokens( + input: ResolveDocxThemeTokensOptions +): DocxThemeTokenSet { + const snapshot = docxThemeStyleSnapshotSchema.parse(input.snapshot); + const diagnostics: DocxThemeDiagnostic[] = []; + const documentWidth = snapshot.slots.find( + (entry) => entry.slot === "document" + )?.computed?.width; + const documentWidthPt = documentWidth + ? parseCssLengthToPt(documentWidth) + : undefined; + const documentWidthPx = + documentWidthPt === undefined ? undefined : documentWidthPt / 0.75; + const snapshotBySlot = new Map( + snapshot.slots.map((entry) => [entry.slot, entry]) + ); + const slots: DocxResolvedStyleSlot[] = DOCX_STYLE_SLOTS.map( + (definition) => { + const entry = snapshotBySlot.get(definition.name); + const useComputedCss = + input.config.mode !== "explicit" && + entry?.matched === true && + entry.computed !== undefined; + let resolved: DocxResolvedStyleSlot; + if (useComputedCss && entry.computed) { + const context: SlotNormalizationContext = { + slot: definition.name, + kind: definition.kind, + ...(documentWidthPx === undefined + ? {} + : { documentWidthPx }), + diagnostics, + approximate: false + }; + const normalized = normalizeComputedSlot( + entry.computed, + context + ); + resolved = { + slot: definition.name, + source: "computed-css", + confidence: normalized.confidence, + style: normalized.style + }; + } else { + const missing = entry?.matched !== true; + diagnostics.push({ + severity: missing ? "warning" : "info", + code: + missing + ? "slot-not-found" + : "preset-fallback-used", + message: + missing + ? `样式槽位 ${definition.name} 未命中,使用 ${input.config.basePreset} 预设` + : `显式模式下样式槽位 ${definition.name} 使用 ${input.config.basePreset} 预设`, + slot: definition.name + }); + resolved = { + slot: definition.name, + source: "preset-fallback", + confidence: "fallback", + style: { fontCandidates: [] } + }; + } + if ( + input.config.mode === "auto-with-overrides" || + input.config.mode === "explicit" + ) { + const override = applyOverrides( + definition.name, + resolved.style, + input.config.overrides + ); + if (override.applied) { + diagnostics.push({ + severity: "info", + code: "manifest-override-applied", + message: `主题清单覆盖已应用到 ${definition.name}`, + slot: definition.name + }); + resolved = { + slot: definition.name, + source: "manifest-override", + confidence: "exact", + style: override.style + }; + } + } + return resolved; + } + ); + return docxThemeTokenSetSchema.parse({ + schemaVersion: 1, + themeId: snapshot.themeId, + themeFingerprint: snapshot.themeFingerprint, + mode: input.config.mode, + basePreset: input.config.basePreset, + slots, + diagnostics + }); +} diff --git a/packages/docx-theme-engine/src/runtime.ts b/packages/docx-theme-engine/src/runtime.ts index 65304bc..d84c10d 100644 --- a/packages/docx-theme-engine/src/runtime.ts +++ b/packages/docx-theme-engine/src/runtime.ts @@ -57,6 +57,7 @@ const computedProperties = [ "lineHeight", "letterSpacing", "textAlign", + "direction", "textIndent", "textDecorationLine", "marginTop", @@ -73,10 +74,17 @@ const computedProperties = [ "borderLeft", "width", "maxWidth", + "minHeight", + "height", "breakBefore", "breakAfter", "breakInside", - "display" + "display", + "flexDirection", + "alignItems", + "justifyContent", + "outline", + "outlineOffset" ] as const satisfies readonly (keyof DocxComputedStyle)[]; const baselineCss = ` diff --git a/packages/docx-theme-engine/src/snapshot.ts b/packages/docx-theme-engine/src/snapshot.ts index bb9037e..160d9f7 100644 --- a/packages/docx-theme-engine/src/snapshot.ts +++ b/packages/docx-theme-engine/src/snapshot.ts @@ -13,6 +13,7 @@ export const docxComputedStyleSchema = z.object({ lineHeight: cssValueSchema, letterSpacing: cssValueSchema, textAlign: cssValueSchema, + direction: cssValueSchema, textIndent: cssValueSchema, textDecorationLine: cssValueSchema, marginTop: cssValueSchema, @@ -29,10 +30,17 @@ export const docxComputedStyleSchema = z.object({ borderLeft: cssValueSchema, width: cssValueSchema, maxWidth: cssValueSchema, + minHeight: cssValueSchema, + height: cssValueSchema, breakBefore: cssValueSchema, breakAfter: cssValueSchema, breakInside: cssValueSchema, - display: cssValueSchema + display: cssValueSchema, + flexDirection: cssValueSchema, + alignItems: cssValueSchema, + justifyContent: cssValueSchema, + outline: cssValueSchema, + outlineOffset: cssValueSchema }); export type DocxComputedStyle = z.infer< diff --git a/packages/docx-theme-engine/src/tokens.ts b/packages/docx-theme-engine/src/tokens.ts index dd6ecc0..00ea93d 100644 --- a/packages/docx-theme-engine/src/tokens.ts +++ b/packages/docx-theme-engine/src/tokens.ts @@ -106,6 +106,11 @@ export const docxSlotStyleTokenSchema = z.object({ }) .optional(), widthPercent: z.number().min(0).max(100).optional(), + minimumHeightPt: z.number().min(0).max(10000).optional(), + verticalAlignment: z + .enum(["top", "center", "bottom"]) + .optional(), + hidden: z.boolean().optional(), pageBreakBefore: z.boolean().optional(), pageBreakAfter: z.boolean().optional(), keepLines: z.boolean().optional(), diff --git a/packages/docx-theme-engine/tests/contracts.test.ts b/packages/docx-theme-engine/tests/contracts.test.ts index 474fe7e..fce4177 100644 --- a/packages/docx-theme-engine/tests/contracts.test.ts +++ b/packages/docx-theme-engine/tests/contracts.test.ts @@ -47,7 +47,7 @@ describe("DOCX 主题映射配置", () => { }) ); expect(result).toMatchObject({ - mode: "explicit", + mode: "auto-with-overrides", basePreset: "technical", overrides: { body: { sizePt: 11 } }, legacyPreset: true @@ -88,6 +88,7 @@ describe("DOCX 主题引擎契约", () => { lineHeight: "24px", letterSpacing: "normal", textAlign: "start", + direction: "ltr", textIndent: "0px", textDecorationLine: "none", marginTop: "0px", @@ -104,10 +105,17 @@ describe("DOCX 主题引擎契约", () => { borderLeft: "0px none rgb(0, 0, 0)", width: "100px", maxWidth: "none", + minHeight: "0px", + height: "24px", breakBefore: "auto", breakAfter: "auto", breakInside: "auto", - display: "block" + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "0px none rgb(0, 0, 0)", + outlineOffset: "0px" }; const result = docxThemeStyleSnapshotSchema.safeParse({ schemaVersion: 1, diff --git a/packages/docx-theme-engine/tests/normalizer.test.ts b/packages/docx-theme-engine/tests/normalizer.test.ts new file mode 100644 index 0000000..23460b2 --- /dev/null +++ b/packages/docx-theme-engine/tests/normalizer.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "vitest"; +import { + DOCX_STYLE_SLOT_NAMES, + millimetersToPoints, + parseCssBorder, + parseCssColor, + parseCssFontFamilies, + parseCssLengthToPt, + resolveDocxThemeTokens, + type DocxComputedStyle, + type DocxStyleSlotName, + type DocxThemeStyleSnapshot +} from "../src/index.js"; + +const computed: DocxComputedStyle = { + fontFamily: + '"Noto Serif CJK SC", "Microsoft YaHei", serif', + fontSize: "16px", + fontWeight: "400", + fontStyle: "normal", + color: "rgb(17, 34, 51)", + backgroundColor: "rgba(0, 0, 0, 0)", + lineHeight: "24px", + letterSpacing: "normal", + textAlign: "start", + direction: "ltr", + textIndent: "32px", + textDecorationLine: "none", + marginTop: "8px", + marginRight: "0px", + marginBottom: "12px", + marginLeft: "0px", + paddingTop: "4px", + paddingRight: "8px", + paddingBottom: "4px", + paddingLeft: "8px", + borderTop: "0px none rgb(17, 34, 51)", + borderRight: "0px none rgb(17, 34, 51)", + borderBottom: "1px solid rgb(221, 221, 221)", + borderLeft: "0px none rgb(17, 34, 51)", + width: "640px", + maxWidth: "none", + minHeight: "0px", + height: "24px", + breakBefore: "auto", + breakAfter: "avoid-page", + breakInside: "avoid", + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "0px none rgb(17, 34, 51)", + outlineOffset: "0px" +}; + +function createSnapshot( + changes: Partial< + Record | null> + > = {} +): DocxThemeStyleSnapshot { + return { + schemaVersion: 1, + themeId: "external-clean", + themeFingerprint: "e".repeat(64), + viewport: { + widthPx: 794, + heightPx: 1123, + deviceScaleFactor: 1 + }, + rootFontSizePx: 16, + slots: DOCX_STYLE_SLOT_NAMES.map((slot) => { + const change = changes[slot]; + if (change === null) { + return { slot, matched: false }; + } + return { + slot, + matched: true, + computed: { + ...computed, + ...change + } + }; + }) + }; +} + +function findSlot( + tokens: ReturnType, + name: DocxStyleSlotName +) { + const slot = tokens.slots.find((entry) => entry.slot === name); + if (!slot) { + throw new Error(`缺少令牌槽位 ${name}`); + } + return slot; +} + +describe("CSS 到 Word 基础值归一化", () => { + it("换算浏览器长度、颜色、字体列表和边框", () => { + expect(parseCssLengthToPt("16px")).toBe(12); + expect(parseCssLengthToPt("25.4mm")).toBe(72); + expect(millimetersToPoints(2)).toBeCloseTo(5.669, 3); + expect(parseCssColor("rgb(17, 34, 51)")).toBe("#112233"); + expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined(); + expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080"); + expect(parseCssColor("#00000080")).toBe("#7f7f7f"); + expect( + parseCssFontFamilies( + '"Source Han Serif SC", "Microsoft YaHei", serif' + ) + ).toEqual([ + "Source Han Serif SC", + "Microsoft YaHei", + "serif" + ]); + expect(parseCssBorder("1px dashed rgb(17, 34, 51)")) + .toEqual({ + widthPt: 0.75, + color: "#112233", + style: "dashed", + approximated: false + }); + expect(parseCssBorder("rgb(119, 119, 119) solid 1px")) + .toMatchObject({ + widthPt: 0.75, + color: "#777777", + style: "single" + }); + expect(parseCssBorder("0px none rgba(0, 0, 0, 0)")) + .toMatchObject({ + widthPt: 0, + style: "none" + }); + }); +}); + +describe("DOCX 主题令牌归一化", () => { + it("按槽位语义映射文本、段落、边框和分页", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot(), + config: { + mode: "auto", + basePreset: "general", + overrides: {}, + legacyPreset: false + } + }); + const paragraph = findSlot(tokens, "paragraph"); + expect(paragraph.source).toBe("computed-css"); + expect(paragraph.confidence).toBe("exact"); + expect(paragraph.style).toMatchObject({ + fontCandidates: [ + "Noto Serif CJK SC", + "Microsoft YaHei", + "serif" + ], + fontSizePt: 12, + color: "#112233", + lineSpacing: 1.5, + alignment: "left", + firstLineIndentPt: 24, + spacingBeforePt: 6, + spacingAfterPt: 9, + keepLines: true, + keepWithNext: true, + borders: { + bottom: { + widthPt: 0.75, + color: "#dddddd", + style: "single" + } + } + }); + expect(findSlot(tokens, "strong").style) + .not.toHaveProperty("firstLineIndentPt"); + expect(findSlot(tokens, "table").style.widthPercent).toBe(100); + expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); + }); + + it("保留封面高度、垂直对齐、轮廓线和隐藏字段语义", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot({ + "tender-cover": { + display: "flex", + flexDirection: "column", + justifyContent: "center", + minHeight: "220mm", + borderBottom: "0px none rgb(17, 34, 51)", + outline: "1px solid rgb(119, 119, 119)", + outlineOffset: "-16px" + }, + "tender-bidder": { + display: "none" + } + }), + config: { + mode: "auto", + basePreset: "tender", + overrides: {}, + legacyPreset: false + } + }); + const cover = findSlot(tokens, "tender-cover"); + expect(cover.confidence).toBe("approximate"); + expect(cover.style).toMatchObject({ + minimumHeightPt: 623.622, + verticalAlignment: "center", + borders: { + top: { + widthPt: 0.75, + color: "#777777", + style: "single" + } + } + }); + expect(findSlot(tokens, "tender-bidder").style.hidden).toBe(true); + expect( + tokens.diagnostics.some( + (entry) => + entry.slot === "tender-cover" && + entry.code === "css-property-unsupported" && + entry.property === "outline-offset" + ) + ).toBe(true); + }); + + it("将超出 Word 能力的装饰边收敛并生成诊断", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot({ + "tender-cover": { + borderTop: "40px solid rgb(18, 85, 138)", + outline: "1px solid rgb(119, 119, 119)", + outlineOffset: "-16px" + } + }), + config: { + mode: "auto", + basePreset: "tender", + overrides: {}, + legacyPreset: false + } + }); + expect( + findSlot(tokens, "tender-cover").style.borders?.top?.widthPt + ).toBe(20); + expect( + tokens.diagnostics.some( + (entry) => + entry.slot === "tender-cover" && + entry.property === "border-top" && + entry.code === "layout-approximated" + ) + ).toBe(true); + expect( + tokens.diagnostics.some( + (entry) => + entry.slot === "tender-cover" && + entry.property === "outline" && + entry.code === "css-property-unsupported" + ) + ).toBe(true); + }); + + it("以清单覆盖自动值并在未命中时显式降级", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot({ caption: null }), + config: { + mode: "auto-with-overrides", + basePreset: "formal", + legacyPreset: true, + overrides: { + body: { + sizePt: 11, + firstLineIndentChars: 2 + }, + hyperlink: { + color: "#aa0000", + underline: false + } + } + } + }); + expect(findSlot(tokens, "paragraph")).toMatchObject({ + source: "manifest-override", + confidence: "exact", + style: { + fontSizePt: 11, + firstLineIndentPt: 22 + } + }); + expect(findSlot(tokens, "hyperlink").style).toMatchObject({ + color: "#aa0000", + underline: false + }); + expect(findSlot(tokens, "caption")).toMatchObject({ + source: "preset-fallback", + confidence: "fallback", + style: { fontCandidates: [] } + }); + expect( + tokens.diagnostics.some( + (entry) => + entry.slot === "caption" && + entry.code === "slot-not-found" + ) + ).toBe(true); + }); + + it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => { + const tokens = resolveDocxThemeTokens({ + snapshot: createSnapshot(), + config: { + mode: "explicit", + basePreset: "technical", + legacyPreset: false, + overrides: { + headings: { + sizesPt: [24, 20, 18, 16, 14, 12] + } + } + } + }); + expect(findSlot(tokens, "paragraph").source) + .toBe("preset-fallback"); + expect(findSlot(tokens, "heading-3")).toMatchObject({ + source: "manifest-override", + style: { fontSizePt: 18 } + }); + }); +}); diff --git a/packages/docx-theme-engine/tests/probe.test.ts b/packages/docx-theme-engine/tests/probe.test.ts index 1a8d900..c80aaca 100644 --- a/packages/docx-theme-engine/tests/probe.test.ts +++ b/packages/docx-theme-engine/tests/probe.test.ts @@ -21,6 +21,7 @@ const computedStyle: DocxComputedStyle = { lineHeight: "28px", letterSpacing: "normal", textAlign: "start", + direction: "ltr", textIndent: "32px", textDecorationLine: "none", marginTop: "0px", @@ -37,10 +38,17 @@ const computedStyle: DocxComputedStyle = { borderLeft: "0px none rgb(17, 17, 17)", width: "640px", maxWidth: "none", + minHeight: "0px", + height: "28px", breakBefore: "auto", breakAfter: "auto", breakInside: "auto", - display: "block" + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "0px none rgb(17, 17, 17)", + outlineOffset: "0px" }; function createRoot(matchedSlots: readonly string[]): DocxStyleProbeRoot { diff --git a/packages/docx-theme-engine/tests/runtime.test.ts b/packages/docx-theme-engine/tests/runtime.test.ts index de01e6b..ede76e8 100644 --- a/packages/docx-theme-engine/tests/runtime.test.ts +++ b/packages/docx-theme-engine/tests/runtime.test.ts @@ -26,6 +26,7 @@ const computed: DocxComputedStyle = { lineHeight: "normal", letterSpacing: "normal", textAlign: "start", + direction: "ltr", textIndent: "0px", textDecorationLine: "none", marginTop: "0px", @@ -42,10 +43,17 @@ const computed: DocxComputedStyle = { borderLeft: "0px none rgb(17, 17, 17)", width: "640px", maxWidth: "none", + minHeight: "0px", + height: "24px", breakBefore: "auto", breakAfter: "auto", breakInside: "auto", - display: "block" + display: "block", + flexDirection: "row", + alignItems: "normal", + justifyContent: "normal", + outline: "0px none rgb(17, 17, 17)", + outlineOffset: "0px" }; function createRuntimeCapture() {