feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+2 -2
View File
@@ -5,14 +5,14 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
当前阶段包含:
- 56 个标准 Markdown 与结构化文档语义槽位;
- 60 个标准 Markdown 与结构化文档语义槽位;
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
- Chromium/Electron 计算样式快照协议;
- 不绑定具体浏览器实现的计算样式采集器;
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
- CSS 长度、颜色、字体、边框和布局值的通用解析器;
- 56 个语义槽位到 Word 文本、段落、表格、媒体和结构令牌的归一化;
- 60 个语义槽位到 Word 文本、段落、表格、媒体和结构令牌的归一化;
- 封面最小高度、垂直对齐、轮廓线和隐藏字段语义;
- 自动映射、显式覆盖和预设降级配置;
- 样式来源、映射置信度和诊断协议;
@@ -60,7 +60,11 @@ export function readDocxComputedStyle(
borderBottom: style.borderBottom,
borderLeft: style.borderLeft,
width: style.width,
...(style.containingBlockWidth
? { containingBlockWidth: style.containingBlockWidth }
: {}),
maxWidth: style.maxWidth,
minWidth: style.minWidth,
minHeight: style.minHeight,
height: style.height,
breakBefore: style.breakBefore,
@@ -69,7 +73,11 @@ export function readDocxComputedStyle(
display: style.display,
flexDirection: style.flexDirection,
alignItems: style.alignItems,
...(style.alignSelf ? { alignSelf: style.alignSelf } : {}),
justifyContent: style.justifyContent,
...(style.contentPaddingLeft
? { contentPaddingLeft: style.contentPaddingLeft }
: {}),
outline: style.outline,
outlineOffset: style.outlineOffset
};
+71 -2
View File
@@ -463,11 +463,21 @@ function normalizeComputedSlot(
};
}
}
if (widthSlots.has(context.slot) && context.documentWidthPx) {
const containingBlockWidthPt = computed.containingBlockWidth
? parseCssLengthToPt(computed.containingBlockWidth)
: undefined;
const relativeWidthPx =
containingBlockWidthPt !== undefined
? containingBlockWidthPt / 0.75
: context.documentWidthPx;
if (
(widthSlots.has(context.slot) || context.kind === "structure") &&
relativeWidthPx
) {
const widthPt = parseCssLengthToPt(computed.width);
const widthPx = widthPt === undefined ? undefined : widthPt / 0.75;
if (widthPx !== undefined) {
const percent = (widthPx / context.documentWidthPx) * 100;
const percent = (widthPx / relativeWidthPx) * 100;
style.widthPercent = Math.round(
Math.max(0, Math.min(100, percent))
);
@@ -482,6 +492,27 @@ function normalizeComputedSlot(
}
}
}
if (context.kind === "structure" && context.documentWidthPx) {
const percentage = /^([+-]?(?:\d+\.?\d*|\.\d+))%$/u.exec(
computed.minWidth
);
const minimumWidthPercent = percentage
? Number.parseFloat(percentage[1]!)
: (() => {
const minimumWidthPt = parseCssLengthToPt(computed.minWidth);
return minimumWidthPt === undefined
? undefined
: ((minimumWidthPt / 0.75) / context.documentWidthPx) * 100;
})();
if (
minimumWidthPercent !== undefined &&
minimumWidthPercent > 0
) {
style.minimumWidthPercent = roundDocxValue(
Math.min(100, minimumWidthPercent)
);
}
}
const minimumHeightPt = length(
context,
"min-height",
@@ -491,6 +522,15 @@ function normalizeComputedSlot(
if (minimumHeightPt !== undefined && minimumHeightPt > 0) {
style.minimumHeightPt = minimumHeightPt;
}
const contentPaddingLeftPt = computed.contentPaddingLeft
? length(context, "content-padding-left", computed.contentPaddingLeft, {
allowAuto: true,
nonNegative: true
})
: undefined;
if (contentPaddingLeftPt !== undefined && contentPaddingLeftPt > 0) {
style.contentPaddingLeftPt = contentPaddingLeftPt;
}
if (computed.display === "none") {
style.hidden = true;
}
@@ -512,6 +552,35 @@ function normalizeComputedSlot(
if (resolved) {
style.verticalAlignment = resolved;
}
const childAlignment = {
"flex-start": "left",
start: "left",
center: "center",
"flex-end": "right",
end: "right",
stretch: "stretch"
} as const;
const resolvedChildAlignment =
childAlignment[
computed.alignItems as keyof typeof childAlignment
];
if (resolvedChildAlignment) {
style.childAlignment = resolvedChildAlignment;
}
}
const selfAlignment = {
"flex-start": "left",
start: "left",
center: "center",
"flex-end": "right",
end: "right",
stretch: "stretch"
} as const;
const resolvedSelfAlignment = computed.alignSelf
? selfAlignment[computed.alignSelf as keyof typeof selfAlignment]
: undefined;
if (resolvedSelfAlignment) {
style.selfAlignment = resolvedSelfAlignment;
}
if (computed.display === "flex" || computed.display === "grid") {
diagnostic(context, {
+3 -3
View File
@@ -13,12 +13,12 @@ const transparentPixel =
export function createDocxStyleProbeMarkup(): string {
return `
<article id="write" ${attribute("document")}>
<h1 ${attribute("heading-1")}>一级标题</h1>
<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>
@@ -56,8 +56,8 @@ export function createDocxStyleProbeMarkup(): string {
</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 class="doc-signature" ${attribute("official-signature")}><p class="doc-signature-issuer" ${attribute("official-signature-issuer")}>示例发文机关</p><time class="doc-signature-date" ${attribute("official-signature-date")}>2026年7月30日</time></section>
<footer class="doc-edition" ${attribute("official-edition")}><p class="doc-copy-to" ${attribute("official-copy-to")}>抄送:示例单位</p><div class="doc-printing-row" ${attribute("official-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")}>
+42
View File
@@ -75,6 +75,7 @@ const computedProperties = [
"borderLeft",
"width",
"maxWidth",
"minWidth",
"minHeight",
"height",
"breakBefore",
@@ -83,6 +84,7 @@ const computedProperties = [
"display",
"flexDirection",
"alignItems",
"alignSelf",
"justifyContent",
"outline",
"outlineOffset"
@@ -104,6 +106,23 @@ body {
margin: 0;
padding: 0;
}
#write pre.md-fences > code,
#write .md-code-pagination-chunk > code {
display: block;
min-width: 0;
max-width: 100%;
padding: 0;
padding-left: 8px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
`;
export function createDocxThemeStyleCaptureScript(
@@ -190,6 +209,29 @@ export function createDocxThemeStyleCaptureScript(
for (const property of payload.properties) {
computed[property] = style[property];
}
const contentElement = element.firstElementChild;
if (contentElement) {
computed.contentPaddingLeft =
probeWindow.getComputedStyle(contentElement).paddingLeft;
}
const containingBlock = element.parentElement;
if (containingBlock) {
const containingStyle = probeWindow.getComputedStyle(
containingBlock
);
const containingRect = containingBlock.getBoundingClientRect();
const horizontalInsets = [
containingStyle.borderLeftWidth,
containingStyle.borderRightWidth,
containingStyle.paddingLeft,
containingStyle.paddingRight
].reduce(
(total, value) => total + (Number.parseFloat(value) || 0),
0
);
computed.containingBlockWidth =
Math.max(0, containingRect.width - horizontalInsets) + "px";
}
return {
slot: definition.name,
matched: true,
+8
View File
@@ -36,7 +36,11 @@ export const DOCX_STYLE_SLOT_NAMES = [
"official-signatory",
"official-title",
"official-signature",
"official-signature-issuer",
"official-signature-date",
"official-edition",
"official-copy-to",
"official-printing-row",
"briefing-masthead",
"briefing-meta",
"briefing-title",
@@ -134,7 +138,11 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
"official-signatory": "structure",
"official-title": "structure",
"official-signature": "structure",
"official-signature-issuer": "structure",
"official-signature-date": "structure",
"official-edition": "structure",
"official-copy-to": "structure",
"official-printing-row": "structure",
"briefing-masthead": "structure",
"briefing-meta": "structure",
"briefing-title": "structure",
@@ -29,7 +29,9 @@ export const docxComputedStyleSchema = z.object({
borderBottom: cssValueSchema,
borderLeft: cssValueSchema,
width: cssValueSchema,
containingBlockWidth: cssValueSchema.optional(),
maxWidth: cssValueSchema,
minWidth: cssValueSchema,
minHeight: cssValueSchema,
height: cssValueSchema,
breakBefore: cssValueSchema,
@@ -38,7 +40,9 @@ export const docxComputedStyleSchema = z.object({
display: cssValueSchema,
flexDirection: cssValueSchema,
alignItems: cssValueSchema,
alignSelf: cssValueSchema.optional(),
justifyContent: cssValueSchema,
contentPaddingLeft: cssValueSchema.optional(),
outline: cssValueSchema,
outlineOffset: cssValueSchema
});
+8
View File
@@ -106,10 +106,18 @@ export const docxSlotStyleTokenSchema = z.object({
})
.optional(),
widthPercent: z.number().min(0).max(100).optional(),
minimumWidthPercent: z.number().min(0).max(100).optional(),
minimumHeightPt: z.number().min(0).max(10000).optional(),
contentPaddingLeftPt: z.number().min(0).max(1000).optional(),
verticalAlignment: z
.enum(["top", "center", "bottom"])
.optional(),
childAlignment: z
.enum(["left", "center", "right", "stretch"])
.optional(),
selfAlignment: z
.enum(["left", "center", "right", "stretch"])
.optional(),
hidden: z.boolean().optional(),
pageBreakBefore: z.boolean().optional(),
pageBreakAfter: z.boolean().optional(),
@@ -105,6 +105,7 @@ describe("DOCX 主题引擎契约", () => {
borderLeft: "0px none rgb(0, 0, 0)",
width: "100px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -40,6 +40,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 34, 51)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -203,12 +204,40 @@ describe("DOCX 主题令牌归一化", () => {
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
});
it("按父级内容区归一化右置百分比结构容器", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"official-signature": {
width: "332.8px",
containingBlockWidth: "640px",
marginLeft: "307.2px",
marginRight: "0px",
textAlign: "center"
}
}),
config: {
mode: "auto",
basePreset: "official",
overrides: {},
legacyPreset: false
}
});
expect(findSlot(tokens, "official-signature").style).toMatchObject({
widthPercent: 52,
leftIndentPt: 230.4,
rightIndentPt: 0,
alignment: "center"
});
});
it("保留封面高度、垂直对齐、轮廓线和隐藏字段语义", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
"tender-cover": {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "220mm",
borderBottom: "0px none rgb(17, 34, 51)",
@@ -216,7 +245,9 @@ describe("DOCX 主题令牌归一化", () => {
outlineOffset: "-16px"
},
"tender-bidder": {
display: "none"
display: "none",
alignSelf: "flex-end",
contentPaddingLeft: "8px"
}
}),
config: {
@@ -231,6 +262,7 @@ describe("DOCX 主题令牌归一化", () => {
expect(cover.style).toMatchObject({
minimumHeightPt: 623.622,
verticalAlignment: "center",
childAlignment: "center",
borders: {
top: {
widthPt: 0.75,
@@ -239,7 +271,11 @@ describe("DOCX 主题令牌归一化", () => {
}
}
});
expect(findSlot(tokens, "tender-bidder").style.hidden).toBe(true);
expect(findSlot(tokens, "tender-bidder").style).toMatchObject({
hidden: true,
selfAlignment: "right",
contentPaddingLeftPt: 6
});
expect(
tokens.diagnostics.some(
(entry) =>
@@ -250,6 +286,31 @@ describe("DOCX 主题令牌归一化", () => {
).toBe(true);
});
it("保留结构槽位的实际宽度以映射 Flex 子项位置", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
document: {
width: "640px",
paddingLeft: "8px",
paddingRight: "8px"
},
"project-report-owner": {
minWidth: "70%"
}
}),
config: {
mode: "auto",
basePreset: "formal",
overrides: {},
legacyPreset: false
}
});
expect(
findSlot(tokens, "project-report-owner").style.minimumWidthPercent
).toBe(70);
});
it("将超出 Word 能力的装饰边收敛并生成诊断", () => {
const tokens = resolveDocxThemeTokens({
snapshot: createSnapshot({
@@ -38,6 +38,7 @@ const computedStyle: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 17, 17)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "28px",
breakBefore: "auto",
@@ -73,6 +74,9 @@ describe("DOCX 标准样式探针", () => {
expect(new Set(slots)).toEqual(
new Set(DOCX_STYLE_SLOT_NAMES)
);
expect(markup).toMatch(
/^<article id="write"[^>]*>\s*<h1 data-docx-slot="heading-1">/u
);
});
it("拒绝缺失和重复槽位的探针", () => {
@@ -43,6 +43,7 @@ const computed: DocxComputedStyle = {
borderLeft: "0px none rgb(17, 17, 17)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
@@ -79,6 +80,10 @@ describe("DOCX 主题样式浏览器运行时", () => {
themeCss: '#write::before { content: "</style><script>x</script>"; }'
});
expect(script).toContain("theme.textContent");
expect(script).toContain("style[property]");
expect(script).toContain("contentPaddingLeft");
expect(script).toContain("padding-left: 8px");
expect(script).toContain("#write pre.md-fences \\u003e code");
expect(script).toContain("frame.remove()");
expect(script).not.toContain("</style><script>x</script>");
});