fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。 按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
This commit is contained in:
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user