From 6086762b4f2e7bf4a6151e615a55720c64626e12 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 21:27:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20DOCX=20=E6=A0=87?= =?UTF-8?q?=E5=87=86=E6=A0=B7=E5=BC=8F=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 13 +- packages/docx-theme-engine/README.md | 11 +- packages/docx-theme-engine/src/collector.ts | 104 ++++++++++++++ packages/docx-theme-engine/src/index.ts | 2 + packages/docx-theme-engine/src/probe.ts | 132 ++++++++++++++++++ packages/docx-theme-engine/src/slots.ts | 60 ++++---- .../docx-theme-engine/tests/probe.test.ts | 122 ++++++++++++++++ 7 files changed, 411 insertions(+), 33 deletions(-) create mode 100644 packages/docx-theme-engine/src/collector.ts create mode 100644 packages/docx-theme-engine/src/probe.ts create mode 100644 packages/docx-theme-engine/tests/probe.test.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 33b0dfd..da34418 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -153,12 +153,15 @@ DOCX 样式令牌,支持自动映射、清单覆盖和预设降级;Front Mat DOCX 映射。 `v0.6.0` 阶段 11 的协议骨架已建立为独立 -`packages/docx-theme-engine`:定义 50 个稳定语义样式槽位、浏览器计算 +`packages/docx-theme-engine`:定义 56 个稳定语义样式槽位、浏览器计算 样式快照、Word 目标样式令牌、属性来源、映射置信度和诊断协议。 `docxStyle` 支持 `auto`、`auto-with-overrides`、`explicit` 三种模式, 普通外部主题缺少 DOCX 声明时默认进入自动映射;现有主题的 `preset` 和 -扁平样式覆盖保持兼容。当前只建立公共契约与 workspace 构建边界,尚未 -实现样式探针页面、Chromium/Electron 采集器或接入 Pandoc 转换链路。 +扁平样式覆盖保持兼容。标准探针 DOM 已覆盖普通文档、公文、简报、项目 +报告和标书,Profile 专属槽位避免同名类在不同结构上下文中互相污染; +平台无关采集器可以按稳定顺序读取全部 `getComputedStyle()` 并保留未 +命中状态。当前尚未实现 Playwright/Electron 页面适配器,也未将快照 +归一化为 Word 令牌或接入 Pandoc 转换链路。 ## 2. 已完成 @@ -1085,8 +1088,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 10:已完成 Word 原生页眉页脚和第一轮全主题视觉审计;完整主题 映射门禁未通过; - 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及 - 快照、令牌、来源、置信度和诊断协议;下一步实现样式探针与 - Chromium/Electron 计算样式采集; + 快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器; + 下一步实现 Playwright/Electron 计算样式页面适配; - 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、 表格宽度和分页控制; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 diff --git a/packages/docx-theme-engine/README.md b/packages/docx-theme-engine/README.md index 42ddc8a..90d32dc 100644 --- a/packages/docx-theme-engine/README.md +++ b/packages/docx-theme-engine/README.md @@ -5,8 +5,10 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立 当前阶段包含: -- 标准 Markdown 与结构化文档语义槽位; +- 56 个标准 Markdown 与结构化文档语义槽位; +- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM; - Chromium/Electron 计算样式快照协议; +- 不绑定具体浏览器实现的计算样式采集器; - Word 目标样式令牌; - 自动映射、显式覆盖和预设降级配置; - 样式来源、映射置信度和诊断协议; @@ -20,6 +22,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立 - 直接修改 OOXML; - 按主题 ID 维护专属转换分支。 -后续 Chromium 与 Electron 适配器只负责采集标准样式探针的 -`getComputedStyle()` 结果。本包负责将快照归一化为 DOCX 样式令牌, -`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。 +后续 Chromium 与 Electron 适配器只负责挂载探针、应用主题 CSS,并将 +平台的 `getComputedStyle()` 接入本包采集器。本包负责将快照归一化为 +DOCX 样式令牌,`@md-to-pdf/docx-engine` 再消费令牌生成 +`reference.docx` 和最终 OOXML。 diff --git a/packages/docx-theme-engine/src/collector.ts b/packages/docx-theme-engine/src/collector.ts new file mode 100644 index 0000000..1cdbfcd --- /dev/null +++ b/packages/docx-theme-engine/src/collector.ts @@ -0,0 +1,104 @@ +import { + docxThemeStyleSnapshotSchema, + type DocxComputedStyle, + type DocxStyleSlotSnapshot, + type DocxThemeStyleSnapshot +} from "./snapshot.js"; +import { DOCX_STYLE_SLOTS } from "./slots.js"; + +export interface DocxStyleProbeRoot { + querySelector(selector: string): unknown | null; +} + +export type DocxComputedStyleReader = ( + element: unknown +) => Readonly; + +export type DocxComputedStyleDeclaration = { + readonly [Property in keyof DocxComputedStyle]: string; +}; + +export interface CollectDocxThemeStyleSnapshotOptions { + root: DocxStyleProbeRoot; + readComputedStyle: DocxComputedStyleReader; + themeId: string; + themeFingerprint: string; + viewport: { + widthPx: number; + heightPx: number; + deviceScaleFactor: number; + }; + rootFontSizePx: number; +} + +export function readDocxComputedStyle( + style: DocxComputedStyleDeclaration +): DocxComputedStyle { + return { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + color: style.color, + backgroundColor: style.backgroundColor, + lineHeight: style.lineHeight, + letterSpacing: style.letterSpacing, + textAlign: style.textAlign, + textIndent: style.textIndent, + textDecorationLine: style.textDecorationLine, + marginTop: style.marginTop, + marginRight: style.marginRight, + marginBottom: style.marginBottom, + marginLeft: style.marginLeft, + paddingTop: style.paddingTop, + paddingRight: style.paddingRight, + paddingBottom: style.paddingBottom, + paddingLeft: style.paddingLeft, + borderTop: style.borderTop, + borderRight: style.borderRight, + borderBottom: style.borderBottom, + borderLeft: style.borderLeft, + width: style.width, + maxWidth: style.maxWidth, + breakBefore: style.breakBefore, + breakAfter: style.breakAfter, + breakInside: style.breakInside, + display: style.display + }; +} + +export function collectDocxStyleSlotSnapshots( + root: DocxStyleProbeRoot, + readComputedStyle: DocxComputedStyleReader +): DocxStyleSlotSnapshot[] { + return DOCX_STYLE_SLOTS.map((definition) => { + const element = root.querySelector(definition.selector); + if (!element) { + return { + slot: definition.name, + matched: false + }; + } + return { + slot: definition.name, + matched: true, + computed: { ...readComputedStyle(element) } + }; + }); +} + +export function collectDocxThemeStyleSnapshot( + options: CollectDocxThemeStyleSnapshotOptions +): DocxThemeStyleSnapshot { + return docxThemeStyleSnapshotSchema.parse({ + schemaVersion: 1, + themeId: options.themeId, + themeFingerprint: options.themeFingerprint, + viewport: options.viewport, + rootFontSizePx: options.rootFontSizePx, + slots: collectDocxStyleSlotSnapshots( + options.root, + options.readComputedStyle + ) + }); +} diff --git a/packages/docx-theme-engine/src/index.ts b/packages/docx-theme-engine/src/index.ts index 78ab043..5de94ba 100644 --- a/packages/docx-theme-engine/src/index.ts +++ b/packages/docx-theme-engine/src/index.ts @@ -1,4 +1,6 @@ +export * from "./collector.js"; export * from "./configuration.js"; +export * from "./probe.js"; export * from "./slots.js"; export * from "./snapshot.js"; export * from "./tokens.js"; diff --git a/packages/docx-theme-engine/src/probe.ts b/packages/docx-theme-engine/src/probe.ts new file mode 100644 index 0000000..60e3c4c --- /dev/null +++ b/packages/docx-theme-engine/src/probe.ts @@ -0,0 +1,132 @@ +import { + DOCX_STYLE_SLOT_NAMES, + type DocxStyleSlotName +} from "./slots.js"; + +function attribute(slot: DocxStyleSlotName) { + return `data-docx-slot="${slot}"`; +} + +const transparentPixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +export function createDocxStyleProbeMarkup(): string { + return ` +
+ +
+

一级标题

+

二级标题

+

三级标题

+

四级标题

+
五级标题
+
六级标题
+

+ 正文 + 粗体 + 斜体 + 删除线 + inline() + 链接 +

+
  • 无序列表
+
  1. 有序列表
+

引用内容

+
const value = 1;
+ + + +
表头
单元格
+
+ 样式探针 +
图注
+
+

脚注内容

+
+
+
+
秘密 加急
+

示例发文机关

+
+ 示例发〔2026〕1号 + 签发人:张三 +
+
+

公文标题

+

示例发文机关

+

抄送:示例单位

示例办公室
+
+
+
+

工作简报

+
第1期示例单位
+
+

简报标题

+
联系人:示例
+
+
+

示例建设项目

+

可行性研究报告

+

送审稿 V1.0

+

建设单位:示例单位

+

编制单位:示例单位

+ +
+
+

正本

+

示例采购项目

+

ZB-2026-001

+

投标文件

+

技术及商务文件

+

投标人:示例公司

+

法定代表人或授权代表:李四

+ +
+
`.trim(); +} + +export function listDocxStyleProbeSlots( + markup = createDocxStyleProbeMarkup() +): DocxStyleSlotName[] { + const matches = markup.matchAll( + /data-docx-slot="([^"]+)"/gu + ); + return [...matches].map( + (match) => match[1] as DocxStyleSlotName + ); +} + +export function validateDocxStyleProbeMarkup( + markup = createDocxStyleProbeMarkup() +): void { + const actual = listDocxStyleProbeSlots(markup); + const duplicates = actual.filter( + (name, index) => actual.indexOf(name) !== index + ); + const actualSet = new Set(actual); + const missing = DOCX_STYLE_SLOT_NAMES.filter( + (name) => !actualSet.has(name) + ); + const unexpected = actual.filter( + (name) => + !DOCX_STYLE_SLOT_NAMES.includes(name as DocxStyleSlotName) + ); + if (duplicates.length || missing.length || unexpected.length) { + throw new Error( + [ + duplicates.length + ? `重复槽位:${[...new Set(duplicates)].join("、")}` + : "", + missing.length ? `缺少槽位:${missing.join("、")}` : "", + unexpected.length + ? `未知槽位:${[...new Set(unexpected)].join("、")}` + : "" + ] + .filter(Boolean) + .join(";") + ); + } +} diff --git a/packages/docx-theme-engine/src/slots.ts b/packages/docx-theme-engine/src/slots.ts index 566d353..347730f 100644 --- a/packages/docx-theme-engine/src/slots.ts +++ b/packages/docx-theme-engine/src/slots.ts @@ -34,23 +34,29 @@ export const DOCX_STYLE_SLOT_NAMES = [ "official-issue-row", "official-number", "official-signatory", + "official-title", "official-signature", "official-edition", "briefing-masthead", "briefing-meta", + "briefing-title", "briefing-contact", - "cover", - "cover-copy-mark", - "cover-project-name", - "cover-project-number", - "cover-title", - "cover-volume", - "cover-owner", - "cover-prepared-by", - "cover-bidder", - "cover-representative", - "cover-version", - "cover-date" + "project-report-cover", + "project-report-project-name", + "project-report-title", + "project-report-version", + "project-report-owner", + "project-report-prepared-by", + "project-report-date", + "tender-cover", + "tender-copy-mark", + "tender-project-name", + "tender-project-number", + "tender-title", + "tender-volume", + "tender-bidder", + "tender-representative", + "tender-date" ] as const; export const docxStyleSlotNameSchema = z.enum( @@ -126,23 +132,29 @@ const kinds: Record = { "official-issue-row": "structure", "official-number": "structure", "official-signatory": "structure", + "official-title": "structure", "official-signature": "structure", "official-edition": "structure", "briefing-masthead": "structure", "briefing-meta": "structure", + "briefing-title": "structure", "briefing-contact": "structure", - cover: "structure", - "cover-copy-mark": "structure", - "cover-project-name": "structure", - "cover-project-number": "structure", - "cover-title": "structure", - "cover-volume": "structure", - "cover-owner": "structure", - "cover-prepared-by": "structure", - "cover-bidder": "structure", - "cover-representative": "structure", - "cover-version": "structure", - "cover-date": "structure" + "project-report-cover": "structure", + "project-report-project-name": "structure", + "project-report-title": "structure", + "project-report-version": "structure", + "project-report-owner": "structure", + "project-report-prepared-by": "structure", + "project-report-date": "structure", + "tender-cover": "structure", + "tender-copy-mark": "structure", + "tender-project-name": "structure", + "tender-project-number": "structure", + "tender-title": "structure", + "tender-volume": "structure", + "tender-bidder": "structure", + "tender-representative": "structure", + "tender-date": "structure" }; export const DOCX_STYLE_SLOTS: readonly DocxStyleSlotDefinition[] = diff --git a/packages/docx-theme-engine/tests/probe.test.ts b/packages/docx-theme-engine/tests/probe.test.ts new file mode 100644 index 0000000..1a8d900 --- /dev/null +++ b/packages/docx-theme-engine/tests/probe.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + DOCX_STYLE_SLOT_NAMES, + collectDocxStyleSlotSnapshots, + collectDocxThemeStyleSnapshot, + createDocxStyleProbeMarkup, + listDocxStyleProbeSlots, + readDocxComputedStyle, + validateDocxStyleProbeMarkup, + type DocxComputedStyle, + type DocxStyleProbeRoot +} from "../src/index.js"; + +const computedStyle: DocxComputedStyle = { + fontFamily: '"Microsoft YaHei", sans-serif', + fontSize: "16px", + fontWeight: "400", + fontStyle: "normal", + color: "rgb(17, 17, 17)", + backgroundColor: "rgba(0, 0, 0, 0)", + lineHeight: "28px", + letterSpacing: "normal", + textAlign: "start", + textIndent: "32px", + textDecorationLine: "none", + marginTop: "0px", + marginRight: "0px", + marginBottom: "12px", + marginLeft: "0px", + paddingTop: "0px", + paddingRight: "0px", + paddingBottom: "0px", + paddingLeft: "0px", + borderTop: "0px none rgb(17, 17, 17)", + borderRight: "0px none rgb(17, 17, 17)", + borderBottom: "0px none rgb(17, 17, 17)", + borderLeft: "0px none rgb(17, 17, 17)", + width: "640px", + maxWidth: "none", + breakBefore: "auto", + breakAfter: "auto", + breakInside: "auto", + display: "block" +}; + +function createRoot(matchedSlots: readonly string[]): DocxStyleProbeRoot { + const selectors = new Set( + matchedSlots.map( + (slot) => `[data-docx-slot="${slot}"]` + ) + ); + return { + querySelector(selector) { + return selectors.has(selector) ? { selector } : null; + } + }; +} + +describe("DOCX 标准样式探针", () => { + it("每个标准槽位在探针中恰好出现一次", () => { + const markup = createDocxStyleProbeMarkup(); + expect(() => validateDocxStyleProbeMarkup(markup)).not.toThrow(); + const slots = listDocxStyleProbeSlots(markup); + expect(slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); + expect(new Set(slots)).toEqual( + new Set(DOCX_STYLE_SLOT_NAMES) + ); + }); + + it("拒绝缺失和重复槽位的探针", () => { + const markup = createDocxStyleProbeMarkup() + .replace('data-docx-slot="paragraph"', "") + .replace( + "", + '

重复

' + ); + expect(() => validateDocxStyleProbeMarkup(markup)).toThrow( + /重复槽位:strong;缺少槽位:paragraph/u + ); + }); +}); + +describe("DOCX 计算样式采集", () => { + it("按稳定槽位顺序采集并保留未命中状态", () => { + const slots = collectDocxStyleSlotSnapshots( + createRoot(["document", "paragraph"]), + () => readDocxComputedStyle(computedStyle) + ); + expect(slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length); + expect(slots[0]).toMatchObject({ + slot: "document", + matched: true, + computed: { fontSize: "16px" } + }); + expect( + slots.find((entry) => entry.slot === "paragraph") + ).toMatchObject({ matched: true }); + expect( + slots.find((entry) => entry.slot === "heading-1") + ).toEqual({ slot: "heading-1", matched: false }); + }); + + it("生成可校验且与平台实现无关的主题快照", () => { + const snapshot = collectDocxThemeStyleSnapshot({ + root: createRoot(DOCX_STYLE_SLOT_NAMES), + readComputedStyle: () => + readDocxComputedStyle(computedStyle), + themeId: "external-clean", + themeFingerprint: "c".repeat(64), + viewport: { + widthPx: 794, + heightPx: 1123, + deviceScaleFactor: 1 + }, + rootFontSizePx: 16 + }); + expect(snapshot.slots).toHaveLength( + DOCX_STYLE_SLOT_NAMES.length + ); + expect(snapshot.slots.every((slot) => slot.matched)).toBe(true); + }); +});