fix: 修复DOCX独立封面视觉门禁

统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
This commit is contained in:
SkyJourney
2026-08-03 18:15:21 +08:00
parent 9dee7e8605
commit b275c671fc
19 changed files with 785 additions and 82 deletions
@@ -898,6 +898,7 @@ export class PagedDocumentRuntime {
const finalizeStartedAt = performance.now();
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
@@ -167,11 +167,6 @@ body,
page-break-after: always !important;
}
#write [data-semantic-region="cover"][data-semantic-break-after="next-page"] {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
#write {
width: 100% !important;
max-width: none !important;
@@ -1,4 +1,105 @@
const COVER_HEIGHT_EPSILON_PX = 0.5;
const COVER_PAGE_BREAK_SAFETY_PX = 8;
function pixelValue(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function constrainCoverChildSpacing(
cover: HTMLElement,
pageContentHeightPx: number
) {
const coverStyle = getComputedStyle(cover);
const availableHeightPx = Math.max(
0,
pageContentHeightPx -
pixelValue(coverStyle.paddingTop) -
pixelValue(coverStyle.paddingBottom) -
pixelValue(coverStyle.borderTopWidth) -
pixelValue(coverStyle.borderBottomWidth)
);
const children = Array.from(cover.children).flatMap((child) => {
if (!(child instanceof HTMLElement)) {
return [];
}
const style = getComputedStyle(child);
if (style.display === "none" || style.position === "absolute") {
return [];
}
return [{
element: child,
heightPx: child.getBoundingClientRect().height,
marginTopPx: pixelValue(style.marginTop),
marginBottomPx: pixelValue(style.marginBottom)
}];
});
const fixedHeightPx = children.reduce(
(total, child) => total + child.heightPx,
0
);
const spacingHeightPx = children.reduce(
(total, child) =>
total + child.marginTopPx + child.marginBottomPx,
0
);
if (
children.length === 0 ||
spacingHeightPx <= COVER_HEIGHT_EPSILON_PX ||
fixedHeightPx + spacingHeightPx <=
availableHeightPx + COVER_HEIGHT_EPSILON_PX
) {
return 1;
}
const spacingScale = Math.min(
1,
Math.max(0, availableHeightPx - fixedHeightPx) / spacingHeightPx
);
for (const child of children) {
child.element.style.marginTop =
`${child.marginTopPx * spacingScale}px`;
child.element.style.marginBottom =
`${child.marginBottomPx * spacingScale}px`;
}
cover.dataset.semanticCoverSpacingScale =
spacingScale.toFixed(6);
return spacingScale;
}
function stabilizeConstrainedCoverFlow(cover: HTMLElement) {
const style = getComputedStyle(cover);
if (style.display !== "flex" && style.display !== "inline-flex") {
return;
}
const layout = cover.ownerDocument.createElement("div");
layout.dataset.semanticCoverLayout = "flex";
layout.style.display = "flex";
layout.style.width = "100%";
layout.style.height = "100%";
layout.style.minHeight = "0";
layout.style.flexDirection = style.flexDirection;
layout.style.alignItems = style.alignItems;
layout.style.justifyContent = style.justifyContent;
layout.append(...Array.from(cover.childNodes));
cover.append(layout);
cover.style.display = "block";
}
function insertCoverPageBreakAnchor(cover: HTMLElement) {
const anchor = cover.ownerDocument.createElement("div");
anchor.dataset.semanticCoverPageBreakAnchor = "true";
anchor.setAttribute("aria-hidden", "true");
anchor.style.cssText = [
"height: 0",
"margin: 0",
"padding: 0",
"border: 0",
"line-height: 0"
].join(";");
anchor.style.setProperty("break-after", "page", "important");
anchor.style.setProperty("page-break-after", "always", "important");
cover.after(anchor);
}
export const semanticCoverSelector =
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]';
@@ -21,12 +122,24 @@ export function constrainSemanticCoversToPage(
) {
continue;
}
const constrainedHeight = `${pageContentHeightPx}px`;
const fittedHeightPx = Math.max(
1,
pageContentHeightPx - COVER_PAGE_BREAK_SAFETY_PX
);
const constrainedHeight = `${fittedHeightPx}px`;
cover.style.boxSizing = "border-box";
cover.style.height = constrainedHeight;
cover.style.minHeight = constrainedHeight;
cover.style.maxHeight = constrainedHeight;
cover.style.overflow = "hidden";
cover.style.setProperty("break-inside", "auto", "important");
cover.style.setProperty("page-break-inside", "auto", "important");
cover.dataset.semanticCoverFit = "constrained";
cover.dataset.semanticCoverPageBreak = "natural";
cover.removeAttribute("data-semantic-break-after");
constrainCoverChildSpacing(cover, fittedHeightPx);
stabilizeConstrainedCoverFlow(cover);
insertCoverPageBreakAnchor(cover);
constrainedCount += 1;
}
return constrainedCount;
@@ -213,11 +213,11 @@ describe("分页预览协议", () => {
expect(documentGeometryCss).toContain(
"page-break-after: always !important"
);
expect(documentGeometryCss).toContain(
'[data-semantic-region="cover"]'
expect(documentGeometryCss).not.toContain(
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]'
);
expect(documentGeometryCss).toContain(
"break-inside: avoid !important"
expect(documentGeometryCss).not.toContain(
"break-after: auto !important"
);
});
@@ -14,19 +14,44 @@ function createCover(height: number) {
const cover = root.querySelector<HTMLElement>("header")!;
cover.getBoundingClientRect = () =>
({ height } as DOMRect);
document.body.append(root);
return { root, cover };
}
describe("语义封面页面适配", () => {
it("只将超过横向页面内容区的封面收束为单页高度", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.style.alignItems = "center";
cover.style.justifyContent = "center";
expect(constrainSemanticCoversToPage(root, 680)).toBe(1);
expect(cover.style.boxSizing).toBe("border-box");
expect(cover.style.height).toBe("680px");
expect(cover.style.minHeight).toBe("680px");
expect(cover.style.maxHeight).toBe("680px");
expect(cover.style.height).toBe("672px");
expect(cover.style.minHeight).toBe("672px");
expect(cover.style.maxHeight).toBe("672px");
expect(cover.style.overflow).toBe("hidden");
expect(cover.style.display).toBe("block");
expect(cover.style.getPropertyValue("break-inside")).toBe("auto");
expect(cover.style.getPropertyPriority("break-inside")).toBe("important");
expect(cover.dataset.semanticCoverFit).toBe("constrained");
expect(cover.dataset.semanticCoverPageBreak).toBe("natural");
expect(cover.hasAttribute("data-semantic-break-after")).toBe(false);
const anchor = cover.nextElementSibling as HTMLElement | null;
expect(anchor?.dataset.semanticCoverPageBreakAnchor).toBe("true");
expect(anchor?.style.height).toBe("0px");
expect(anchor?.style.getPropertyValue("break-after")).toBe("page");
expect(anchor?.style.getPropertyPriority("break-after")).toBe(
"important"
);
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
);
expect(layout?.style.display).toBe("flex");
expect(layout?.style.flexDirection).toBe("column");
expect(layout?.style.alignItems).toBe("center");
expect(layout?.style.justifyContent).toBe("center");
});
it("纵向页面可以容纳主题封面时保持主题原始高度", () => {
@@ -36,4 +61,29 @@ describe("语义封面页面适配", () => {
expect(cover.getAttribute("style")).toBeNull();
expect(cover.dataset.semanticCoverFit).toBeUndefined();
});
it("横向页面按同一比例压缩封面子元素纵向间距", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.innerHTML = `
<p style="margin-top: 50px; margin-bottom: 50px">标题</p>
<p style="margin-top: 50px; margin-bottom: 50px">日期</p>
`;
for (const child of Array.from(cover.children)) {
Object.defineProperty(child, "getBoundingClientRect", {
value: () => ({ height: 100 } as DOMRect),
});
}
expect(constrainSemanticCoversToPage(root, 300)).toBe(1);
expect(cover.dataset.semanticCoverSpacingScale).toBe("0.460000");
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
)!;
for (const child of Array.from(layout.children)) {
expect((child as HTMLElement).style.marginTop).toBe("23px");
expect((child as HTMLElement).style.marginBottom).toBe("23px");
}
});
});