feat: 实现 DOCX 主题令牌归一化
This commit is contained in:
@@ -7,7 +7,9 @@ import {
|
|||||||
DOCX_STYLE_SLOT_NAMES,
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
createDocxThemeStyleCaptureScript,
|
createDocxThemeStyleCaptureScript,
|
||||||
createDocxThemeStyleFingerprint,
|
createDocxThemeStyleFingerprint,
|
||||||
parseDocxThemeStyleRuntimeCapture
|
normalizeDocxThemeMappingConfig,
|
||||||
|
parseDocxThemeStyleRuntimeCapture,
|
||||||
|
resolveDocxThemeTokens
|
||||||
} from "@md-to-pdf/docx-theme-engine";
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
import { app, BrowserWindow } from "electron";
|
import { app, BrowserWindow } from "electron";
|
||||||
|
|
||||||
@@ -113,8 +115,9 @@ try {
|
|||||||
.filter((theme) => theme.source === "bundled")
|
.filter((theme) => theme.source === "bundled")
|
||||||
.sort((first, second) =>
|
.sort((first, second) =>
|
||||||
first.id.localeCompare(second.id, "en")
|
first.id.localeCompare(second.id, "en")
|
||||||
);
|
);
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
|
const tokenSets = [];
|
||||||
for (const theme of bundledThemes) {
|
for (const theme of bundledThemes) {
|
||||||
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
|
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
|
||||||
const themeCss = await application.getThemeCss(theme.id);
|
const themeCss = await application.getThemeCss(theme.id);
|
||||||
@@ -144,11 +147,67 @@ try {
|
|||||||
`主题 ${theme.id} 存在未命中槽位`
|
`主题 ${theme.id} 存在未命中槽位`
|
||||||
);
|
);
|
||||||
snapshots.push(snapshot);
|
snapshots.push(snapshot);
|
||||||
|
tokenSets.push(
|
||||||
|
resolveDocxThemeTokens({
|
||||||
|
snapshot,
|
||||||
|
config: normalizeDocxThemeMappingConfig(theme)
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert(
|
assert(
|
||||||
snapshots.length === 14,
|
snapshots.length === 14,
|
||||||
`内置主题数量应为 14,实际为 ${snapshots.length}`
|
`内置主题数量应为 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.mkdirSync(outputDirectory, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(outputDirectory, "electron-snapshots.json"),
|
path.join(outputDirectory, "electron-snapshots.json"),
|
||||||
@@ -159,7 +218,10 @@ try {
|
|||||||
chromiumVersion: process.versions.chrome,
|
chromiumVersion: process.versions.chrome,
|
||||||
themeCount: snapshots.length,
|
themeCount: snapshots.length,
|
||||||
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
snapshots
|
crossEngineTokenMismatches:
|
||||||
|
crossEngineMismatches.length,
|
||||||
|
snapshots,
|
||||||
|
tokenSets
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -173,7 +235,14 @@ try {
|
|||||||
themes: snapshots.length,
|
themes: snapshots.length,
|
||||||
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
totalSlots:
|
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 {
|
} finally {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const computed: DocxComputedStyle = {
|
|||||||
lineHeight: "normal",
|
lineHeight: "normal",
|
||||||
letterSpacing: "normal",
|
letterSpacing: "normal",
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
textIndent: "0px",
|
textIndent: "0px",
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
marginTop: "0px",
|
marginTop: "0px",
|
||||||
@@ -32,10 +33,17 @@ const computed: DocxComputedStyle = {
|
|||||||
borderLeft: "0px none rgb(0, 0, 0)",
|
borderLeft: "0px none rgb(0, 0, 0)",
|
||||||
width: "640px",
|
width: "640px",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
breakBefore: "auto",
|
breakBefore: "auto",
|
||||||
breakAfter: "auto",
|
breakAfter: "auto",
|
||||||
breakInside: "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 = {
|
const request: DocxThemeStyleCaptureRequest = {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createApplicationService } from "@md-to-pdf/application";
|
import { createApplicationService } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
DOCX_STYLE_SLOT_NAMES,
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
createDocxThemeStyleFingerprint
|
createDocxThemeStyleFingerprint,
|
||||||
|
normalizeDocxThemeMappingConfig,
|
||||||
|
resolveDocxThemeTokens
|
||||||
} from "@md-to-pdf/docx-theme-engine";
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
||||||
@@ -99,8 +101,9 @@ try {
|
|||||||
.filter((theme) => theme.source === "bundled")
|
.filter((theme) => theme.source === "bundled")
|
||||||
.sort((first, second) =>
|
.sort((first, second) =>
|
||||||
first.id.localeCompare(second.id, "en")
|
first.id.localeCompare(second.id, "en")
|
||||||
);
|
);
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
|
const tokenSets = [];
|
||||||
for (const theme of bundledThemes) {
|
for (const theme of bundledThemes) {
|
||||||
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
||||||
const themeCss = await application.getThemeCss(theme.id);
|
const themeCss = await application.getThemeCss(theme.id);
|
||||||
@@ -126,11 +129,28 @@ try {
|
|||||||
.join("、")}`
|
.join("、")}`
|
||||||
);
|
);
|
||||||
snapshots.push(snapshot);
|
snapshots.push(snapshot);
|
||||||
|
tokenSets.push(
|
||||||
|
resolveDocxThemeTokens({
|
||||||
|
snapshot,
|
||||||
|
config: normalizeDocxThemeMappingConfig(theme)
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert(
|
assert(
|
||||||
snapshots.length === 14,
|
snapshots.length === 14,
|
||||||
`内置主题数量应为 14,实际为 ${snapshots.length}`
|
`内置主题数量应为 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(
|
const paragraphStyles = new Set(
|
||||||
snapshots.map(
|
snapshots.map(
|
||||||
(snapshot) =>
|
(snapshot) =>
|
||||||
@@ -151,7 +171,8 @@ try {
|
|||||||
chromiumVersion: browser.version(),
|
chromiumVersion: browser.version(),
|
||||||
themeCount: snapshots.length,
|
themeCount: snapshots.length,
|
||||||
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
snapshots
|
snapshots,
|
||||||
|
tokenSets
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -164,7 +185,12 @@ try {
|
|||||||
themes: snapshots.length,
|
themes: snapshots.length,
|
||||||
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
totalSlots:
|
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();
|
await context.close();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const computed: DocxComputedStyle = {
|
|||||||
lineHeight: "normal",
|
lineHeight: "normal",
|
||||||
letterSpacing: "normal",
|
letterSpacing: "normal",
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
textIndent: "0px",
|
textIndent: "0px",
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
marginTop: "0px",
|
marginTop: "0px",
|
||||||
@@ -32,10 +33,17 @@ const computed: DocxComputedStyle = {
|
|||||||
borderLeft: "0px none rgb(0, 0, 0)",
|
borderLeft: "0px none rgb(0, 0, 0)",
|
||||||
width: "640px",
|
width: "640px",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
breakBefore: "auto",
|
breakBefore: "auto",
|
||||||
breakAfter: "auto",
|
breakAfter: "auto",
|
||||||
breakInside: "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 = {
|
const request: DocxThemeStyleCaptureRequest = {
|
||||||
|
|||||||
+16
-3
@@ -168,8 +168,20 @@ DOCX 映射。
|
|||||||
Electron Chromium 150 中采集 56 个槽位,共检查两组各 784 个槽位;
|
Electron Chromium 150 中采集 56 个槽位,共检查两组各 784 个槽位;
|
||||||
两端逐槽位 `font-family`、`font-size` 和 `color` 差异为 0。统一复现
|
两端逐槽位 `font-family`、`font-size` 和 `color` 差异为 0。统一复现
|
||||||
命令为 `npm run verify:docx-theme-styles`,结果写入被 Git 忽略的
|
命令为 `npm run verify:docx-theme-styles`,结果写入被 Git 忽略的
|
||||||
`output/docx-theme-styles/`。当前尚未将快照归一化为 Word 令牌或接入
|
`output/docx-theme-styles/`。
|
||||||
Pandoc 转换链路。
|
|
||||||
|
阶段 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. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
@@ -1098,7 +1110,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
|
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
|
||||||
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
|
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
|
||||||
Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过;
|
Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过;
|
||||||
下一步实现 CSS 计算值到 Word 样式令牌的归一化;
|
CSS 计算值到 Word 样式令牌的通用归一化、清单覆盖、预设降级和结构
|
||||||
|
近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌;
|
||||||
- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、
|
- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、
|
||||||
表格宽度和分页控制;
|
表格宽度和分页控制;
|
||||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
|||||||
- 不绑定具体浏览器实现的计算样式采集器;
|
- 不绑定具体浏览器实现的计算样式采集器;
|
||||||
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
|
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
|
||||||
- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
|
- 基于主题 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 适配器负责在现有受限浏览器生命周期
|
Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
|
||||||
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
||||||
本包下一步负责将快照归一化为 DOCX 样式令牌,
|
`@md-to-pdf/docx-engine` 后续消费本包令牌生成 `reference.docx` 和最终
|
||||||
`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。
|
OOXML。Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似
|
||||||
|
令牌与诊断,供语义文档结构层选择 Word 表格、分节或段落布局实现。
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function readDocxComputedStyle(
|
|||||||
lineHeight: style.lineHeight,
|
lineHeight: style.lineHeight,
|
||||||
letterSpacing: style.letterSpacing,
|
letterSpacing: style.letterSpacing,
|
||||||
textAlign: style.textAlign,
|
textAlign: style.textAlign,
|
||||||
|
direction: style.direction,
|
||||||
textIndent: style.textIndent,
|
textIndent: style.textIndent,
|
||||||
textDecorationLine: style.textDecorationLine,
|
textDecorationLine: style.textDecorationLine,
|
||||||
marginTop: style.marginTop,
|
marginTop: style.marginTop,
|
||||||
@@ -60,10 +61,17 @@ export function readDocxComputedStyle(
|
|||||||
borderLeft: style.borderLeft,
|
borderLeft: style.borderLeft,
|
||||||
width: style.width,
|
width: style.width,
|
||||||
maxWidth: style.maxWidth,
|
maxWidth: style.maxWidth,
|
||||||
|
minHeight: style.minHeight,
|
||||||
|
height: style.height,
|
||||||
breakBefore: style.breakBefore,
|
breakBefore: style.breakBefore,
|
||||||
breakAfter: style.breakAfter,
|
breakAfter: style.breakAfter,
|
||||||
breakInside: style.breakInside,
|
breakInside: style.breakInside,
|
||||||
display: style.display
|
display: style.display,
|
||||||
|
flexDirection: style.flexDirection,
|
||||||
|
alignItems: style.alignItems,
|
||||||
|
justifyContent: style.justifyContent,
|
||||||
|
outline: style.outline,
|
||||||
|
outlineOffset: style.outlineOffset
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function normalizeDocxThemeMappingConfig(
|
|||||||
style.basePreset === undefined;
|
style.basePreset === undefined;
|
||||||
const mode =
|
const mode =
|
||||||
style?.mode ??
|
style?.mode ??
|
||||||
(legacyPreset ? "explicit" : "auto");
|
(legacyPreset ? "auto-with-overrides" : "auto");
|
||||||
const basePreset =
|
const basePreset =
|
||||||
style?.basePreset ??
|
style?.basePreset ??
|
||||||
style?.preset ??
|
style?.preset ??
|
||||||
|
|||||||
@@ -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<Record<string, number>> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export * from "./collector.js";
|
export * from "./collector.js";
|
||||||
export * from "./configuration.js";
|
export * from "./configuration.js";
|
||||||
|
export * from "./css-values.js";
|
||||||
export * from "./fingerprint.js";
|
export * from "./fingerprint.js";
|
||||||
|
export * from "./normalizer.js";
|
||||||
export * from "./probe.js";
|
export * from "./probe.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
export * from "./snapshot-cache.js";
|
export * from "./snapshot-cache.js";
|
||||||
|
|||||||
@@ -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<DocxStyleSlotKind>([
|
||||||
|
"document",
|
||||||
|
"paragraph",
|
||||||
|
"list",
|
||||||
|
"structure"
|
||||||
|
]);
|
||||||
|
const widthSlots = new Set<DocxStyleSlotName>([
|
||||||
|
"table",
|
||||||
|
"figure"
|
||||||
|
]);
|
||||||
|
const bodyOverrideSlots = new Set<DocxStyleSlotName>([
|
||||||
|
"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<DocxThemeDiagnostic, "slot">
|
||||||
|
): 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 extends keyof MutableStyle>(
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ const computedProperties = [
|
|||||||
"lineHeight",
|
"lineHeight",
|
||||||
"letterSpacing",
|
"letterSpacing",
|
||||||
"textAlign",
|
"textAlign",
|
||||||
|
"direction",
|
||||||
"textIndent",
|
"textIndent",
|
||||||
"textDecorationLine",
|
"textDecorationLine",
|
||||||
"marginTop",
|
"marginTop",
|
||||||
@@ -73,10 +74,17 @@ const computedProperties = [
|
|||||||
"borderLeft",
|
"borderLeft",
|
||||||
"width",
|
"width",
|
||||||
"maxWidth",
|
"maxWidth",
|
||||||
|
"minHeight",
|
||||||
|
"height",
|
||||||
"breakBefore",
|
"breakBefore",
|
||||||
"breakAfter",
|
"breakAfter",
|
||||||
"breakInside",
|
"breakInside",
|
||||||
"display"
|
"display",
|
||||||
|
"flexDirection",
|
||||||
|
"alignItems",
|
||||||
|
"justifyContent",
|
||||||
|
"outline",
|
||||||
|
"outlineOffset"
|
||||||
] as const satisfies readonly (keyof DocxComputedStyle)[];
|
] as const satisfies readonly (keyof DocxComputedStyle)[];
|
||||||
|
|
||||||
const baselineCss = `
|
const baselineCss = `
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const docxComputedStyleSchema = z.object({
|
|||||||
lineHeight: cssValueSchema,
|
lineHeight: cssValueSchema,
|
||||||
letterSpacing: cssValueSchema,
|
letterSpacing: cssValueSchema,
|
||||||
textAlign: cssValueSchema,
|
textAlign: cssValueSchema,
|
||||||
|
direction: cssValueSchema,
|
||||||
textIndent: cssValueSchema,
|
textIndent: cssValueSchema,
|
||||||
textDecorationLine: cssValueSchema,
|
textDecorationLine: cssValueSchema,
|
||||||
marginTop: cssValueSchema,
|
marginTop: cssValueSchema,
|
||||||
@@ -29,10 +30,17 @@ export const docxComputedStyleSchema = z.object({
|
|||||||
borderLeft: cssValueSchema,
|
borderLeft: cssValueSchema,
|
||||||
width: cssValueSchema,
|
width: cssValueSchema,
|
||||||
maxWidth: cssValueSchema,
|
maxWidth: cssValueSchema,
|
||||||
|
minHeight: cssValueSchema,
|
||||||
|
height: cssValueSchema,
|
||||||
breakBefore: cssValueSchema,
|
breakBefore: cssValueSchema,
|
||||||
breakAfter: cssValueSchema,
|
breakAfter: cssValueSchema,
|
||||||
breakInside: cssValueSchema,
|
breakInside: cssValueSchema,
|
||||||
display: cssValueSchema
|
display: cssValueSchema,
|
||||||
|
flexDirection: cssValueSchema,
|
||||||
|
alignItems: cssValueSchema,
|
||||||
|
justifyContent: cssValueSchema,
|
||||||
|
outline: cssValueSchema,
|
||||||
|
outlineOffset: cssValueSchema
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocxComputedStyle = z.infer<
|
export type DocxComputedStyle = z.infer<
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export const docxSlotStyleTokenSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
widthPercent: z.number().min(0).max(100).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(),
|
pageBreakBefore: z.boolean().optional(),
|
||||||
pageBreakAfter: z.boolean().optional(),
|
pageBreakAfter: z.boolean().optional(),
|
||||||
keepLines: z.boolean().optional(),
|
keepLines: z.boolean().optional(),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe("DOCX 主题映射配置", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
mode: "explicit",
|
mode: "auto-with-overrides",
|
||||||
basePreset: "technical",
|
basePreset: "technical",
|
||||||
overrides: { body: { sizePt: 11 } },
|
overrides: { body: { sizePt: 11 } },
|
||||||
legacyPreset: true
|
legacyPreset: true
|
||||||
@@ -88,6 +88,7 @@ describe("DOCX 主题引擎契约", () => {
|
|||||||
lineHeight: "24px",
|
lineHeight: "24px",
|
||||||
letterSpacing: "normal",
|
letterSpacing: "normal",
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
textIndent: "0px",
|
textIndent: "0px",
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
marginTop: "0px",
|
marginTop: "0px",
|
||||||
@@ -104,10 +105,17 @@ describe("DOCX 主题引擎契约", () => {
|
|||||||
borderLeft: "0px none rgb(0, 0, 0)",
|
borderLeft: "0px none rgb(0, 0, 0)",
|
||||||
width: "100px",
|
width: "100px",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
breakBefore: "auto",
|
breakBefore: "auto",
|
||||||
breakAfter: "auto",
|
breakAfter: "auto",
|
||||||
breakInside: "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({
|
const result = docxThemeStyleSnapshotSchema.safeParse({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|||||||
@@ -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<DocxStyleSlotName, Partial<DocxComputedStyle> | 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<typeof resolveDocxThemeTokens>,
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ const computedStyle: DocxComputedStyle = {
|
|||||||
lineHeight: "28px",
|
lineHeight: "28px",
|
||||||
letterSpacing: "normal",
|
letterSpacing: "normal",
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
textIndent: "32px",
|
textIndent: "32px",
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
marginTop: "0px",
|
marginTop: "0px",
|
||||||
@@ -37,10 +38,17 @@ const computedStyle: DocxComputedStyle = {
|
|||||||
borderLeft: "0px none rgb(17, 17, 17)",
|
borderLeft: "0px none rgb(17, 17, 17)",
|
||||||
width: "640px",
|
width: "640px",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "28px",
|
||||||
breakBefore: "auto",
|
breakBefore: "auto",
|
||||||
breakAfter: "auto",
|
breakAfter: "auto",
|
||||||
breakInside: "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 {
|
function createRoot(matchedSlots: readonly string[]): DocxStyleProbeRoot {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const computed: DocxComputedStyle = {
|
|||||||
lineHeight: "normal",
|
lineHeight: "normal",
|
||||||
letterSpacing: "normal",
|
letterSpacing: "normal",
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
textIndent: "0px",
|
textIndent: "0px",
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
marginTop: "0px",
|
marginTop: "0px",
|
||||||
@@ -42,10 +43,17 @@ const computed: DocxComputedStyle = {
|
|||||||
borderLeft: "0px none rgb(17, 17, 17)",
|
borderLeft: "0px none rgb(17, 17, 17)",
|
||||||
width: "640px",
|
width: "640px",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
breakBefore: "auto",
|
breakBefore: "auto",
|
||||||
breakAfter: "auto",
|
breakAfter: "auto",
|
||||||
breakInside: "auto",
|
breakInside: "auto",
|
||||||
display: "block"
|
display: "block",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "normal",
|
||||||
|
justifyContent: "normal",
|
||||||
|
outline: "0px none rgb(17, 17, 17)",
|
||||||
|
outlineOffset: "0px"
|
||||||
};
|
};
|
||||||
|
|
||||||
function createRuntimeCapture() {
|
function createRuntimeCapture() {
|
||||||
|
|||||||
Reference in New Issue
Block a user