feat: 建立 DOCX 标准样式探针

This commit is contained in:
SkyJourney
2026-07-30 21:27:29 +08:00
parent 6fed666f44
commit 6086762b4f
7 changed files with 411 additions and 33 deletions
+104
View File
@@ -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<DocxComputedStyle>;
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
)
});
}
+2
View File
@@ -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";
+132
View File
@@ -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 `
<article id="write" ${attribute("document")}>
<header class="doc-metadata">
<h1 class="doc-metadata-title" ${attribute("document-title")}>文档标题</h1>
<p class="doc-author" ${attribute("document-author")}>示例作者</p>
</header>
<section class="docx-probe-markdown">
<h1 ${attribute("heading-1")}>一级标题</h1>
<h2 ${attribute("heading-2")}>二级标题</h2>
<h3 ${attribute("heading-3")}>三级标题</h3>
<h4 ${attribute("heading-4")}>四级标题</h4>
<h5 ${attribute("heading-5")}>五级标题</h5>
<h6 ${attribute("heading-6")}>六级标题</h6>
<p ${attribute("paragraph")}>
正文
<strong ${attribute("strong")}>粗体</strong>
<em ${attribute("emphasis")}>斜体</em>
<del ${attribute("strikethrough")}>删除线</del>
<code ${attribute("inline-code")}>inline()</code>
<a href="#probe" ${attribute("hyperlink")}>链接</a>
</p>
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
<blockquote ${attribute("block-quote")}><p>引用内容</p></blockquote>
<pre class="md-fences" ${attribute("code-block")}><code>const value = 1;</code></pre>
<table ${attribute("table")}>
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
</table>
<figure ${attribute("figure")}>
<img src="${transparentPixel}" alt="样式探针" ${attribute("image")}>
<figcaption ${attribute("caption")}>图注</figcaption>
</figure>
<section class="footnotes" ${attribute("footnotes")}><p>脚注内容</p></section>
</section>
<section class="docx-probe-official">
<header class="doc-masthead doc-masthead-official" ${attribute("official-masthead")}>
<div class="doc-classification" ${attribute("official-classification")}>秘密 加急</div>
<p class="doc-issuer" ${attribute("official-issuer")}>示例发文机关</p>
<div class="doc-issue-row" ${attribute("official-issue-row")}>
<span class="doc-number" ${attribute("official-number")}>示例发〔20261号</span>
<span class="doc-signatory" ${attribute("official-signatory")}><span>签发人:</span>张三</span>
</div>
</header>
<h1 class="doc-title" ${attribute("official-title")}>公文标题</h1>
<section class="doc-signature" ${attribute("official-signature")}><p>示例发文机关</p><time>2026年7月30日</time></section>
<footer class="doc-edition" ${attribute("official-edition")}><p class="doc-copy-to">抄送:示例单位</p><div class="doc-printing-row"><span>示例办公室</span><time>2026年7月30日印发</time></div></footer>
</section>
<section class="docx-probe-briefing">
<header class="doc-masthead doc-masthead-briefing" ${attribute("briefing-masthead")}>
<p class="doc-briefing-masthead">工作简报</p>
<div class="doc-briefing-meta" ${attribute("briefing-meta")}><span>第1期</span><span>示例单位</span><time>2026年7月30日</time></div>
</header>
<h1 class="doc-title" ${attribute("briefing-title")}>简报标题</h1>
<footer class="doc-briefing-contact" ${attribute("briefing-contact")}>联系人:示例</footer>
</section>
<header class="doc-cover doc-cover-project-report" ${attribute("project-report-cover")}>
<p class="doc-cover-project-name" ${attribute("project-report-project-name")}>示例建设项目</p>
<h1 class="doc-cover-title" ${attribute("project-report-title")}>可行性研究报告</h1>
<p class="doc-cover-version" ${attribute("project-report-version")}>送审稿 V1.0</p>
<p class="doc-cover-owner" ${attribute("project-report-owner")}><span>建设单位:</span>示例单位</p>
<p class="doc-cover-prepared-by" ${attribute("project-report-prepared-by")}><span>编制单位:</span>示例单位</p>
<time class="doc-cover-date" ${attribute("project-report-date")}>2026年7月</time>
</header>
<header class="doc-cover doc-cover-tender" ${attribute("tender-cover")}>
<p class="doc-cover-copy-mark" ${attribute("tender-copy-mark")}>正本</p>
<p class="doc-cover-project-name" ${attribute("tender-project-name")}>示例采购项目</p>
<p class="doc-cover-project-number" ${attribute("tender-project-number")}>ZB-2026-001</p>
<h1 class="doc-cover-title" ${attribute("tender-title")}>投标文件</h1>
<p class="doc-cover-volume" ${attribute("tender-volume")}>技术及商务文件</p>
<p class="doc-cover-bidder" ${attribute("tender-bidder")}><span>投标人:</span>示例公司</p>
<p class="doc-cover-representative" ${attribute("tender-representative")}><span>法定代表人或授权代表:</span>李四</p>
<time class="doc-cover-date" ${attribute("tender-date")}>2026年7月30日</time>
</header>
</article>`.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("")
);
}
}
+36 -24
View File
@@ -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<DocxStyleSlotName, DocxStyleSlotKind> = {
"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[] =