feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user