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
@@ -39,6 +39,12 @@ type DocxBorderToken = NonNullable<
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
// Chromium 预留 6ptWord/WPS 的分节承载段落还需要额外分页保留量。
const COVER_PAGE_BREAK_SAFETY_PT = 6;
const COVER_CONTENT_CENTER_BIAS_PT = 1.5;
const OFFICE_END_OF_CELL_SAFETY_PT = 18;
const MAX_WORD_BORDER_WIDTH_PT = 12;
const OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR = 1.25;
export interface GeneratedDocxStructureReport {
containerCount: number;
@@ -592,11 +598,54 @@ function distinctLayoutSpacerColor(backgroundColor: string) {
return (numeric ^ 1).toString(16).toUpperCase().padStart(6, "0");
}
function appendContainerDecorationParagraph(
cell: XmlElement,
heightPt: number,
color: string
) {
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
const properties = appendElement(paragraph, WORD_NAMESPACE, "w:pPr");
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0",
"w:line": String(pointsToTwips(heightPt)),
"w:lineRule": "exact"
});
appendElement(properties, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": color
});
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
const runProperties = appendElement(run, WORD_NAMESPACE, "w:rPr");
appendElement(runProperties, WORD_NAMESPACE, "w:color", {
"w:val": color
});
appendElement(runProperties, WORD_NAMESPACE, "w:sz", {
"w:val": "2"
});
appendElement(runProperties, WORD_NAMESPACE, "w:szCs", {
"w:val": "2"
});
const text = appendElement(run, WORD_NAMESPACE, "w:t");
text.textContent = ".";
}
function createConstrainedContainerLayout(
rowToReplace: XmlElement,
elements: readonly XmlElement[],
paragraphTokens: ReadonlyMap<string, DocxSlotStyleToken>,
availableHeightPt: number,
verticalInsets: {
topPt: number;
bottomPt: number;
borderPt: number;
flowStartSafetyPt: number;
centerBiasPt: number;
preserveTerminalSafety?: boolean;
topDecoration?: { heightPt: number; color: string };
bottomDecoration?: { heightPt: number; color: string };
},
spacerColor: string
) {
const entries = elements.flatMap((element) => {
@@ -624,28 +673,48 @@ function createConstrainedContainerLayout(
total + entry.metrics.before + entry.metrics.after,
0
);
// Word and WPS both reserve an implementation-defined end-of-cell line box.
// Keep one final content line out of the elastic spacer budget so an exact
// full-page row never clips the last editable cover field.
const terminalHeightPt = entries.at(-1)!.metrics.fixed;
const terminalHeightPt = Math.min(
OFFICE_END_OF_CELL_SAFETY_PT,
entries.at(-1)!.metrics.fixed
);
const usableHeightPt = Math.max(
1,
availableHeightPt - terminalHeightPt
availableHeightPt -
verticalInsets.topPt -
verticalInsets.bottomPt -
verticalInsets.borderPt -
verticalInsets.flowStartSafetyPt -
(verticalInsets.topDecoration?.heightPt ?? 0) -
(verticalInsets.bottomDecoration?.heightPt ?? 0) -
terminalHeightPt
);
const hasParagraphDecoration = Boolean(
verticalInsets.topDecoration || verticalInsets.bottomDecoration
);
const spacingBudgetHeightPt =
usableHeightPt +
(hasParagraphDecoration || verticalInsets.preserveTerminalSafety
? 0
: terminalHeightPt);
const spacingScale =
spacingHeightPt > 0
? Math.min(
1,
Math.max(0, usableHeightPt - fixedHeightPt) /
Math.max(0, spacingBudgetHeightPt - fixedHeightPt) /
spacingHeightPt
)
: 1;
const usedHeightPt =
fixedHeightPt + spacingHeightPt * spacingScale;
const leadingHeightPt = Math.max(
const centeredLeadingHeightPt = Math.max(
0,
(usableHeightPt - usedHeightPt) / 2
);
const leadingHeightPt =
verticalInsets.topPt +
verticalInsets.flowStartSafetyPt +
centeredLeadingHeightPt +
verticalInsets.centerBiasPt;
const cell = firstDirectChild(
rowToReplace,
WORD_NAMESPACE,
@@ -654,6 +723,13 @@ function createConstrainedContainerLayout(
if (!cell) {
return false;
}
if (verticalInsets.topDecoration) {
appendContainerDecorationParagraph(
cell,
verticalInsets.topDecoration.heightPt,
verticalInsets.topDecoration.color
);
}
appendConstrainedSpacerParagraph(cell, leadingHeightPt, spacerColor);
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index]!;
@@ -675,8 +751,21 @@ function createConstrainedContainerLayout(
cell.appendChild(entry.paragraph);
}
const trailingHeightPt =
entries.at(-1)!.metrics.after * spacingScale + leadingHeightPt;
Math.max(
0,
entries.at(-1)!.metrics.after * spacingScale +
verticalInsets.bottomPt +
centeredLeadingHeightPt -
verticalInsets.centerBiasPt
);
appendConstrainedSpacerParagraph(cell, trailingHeightPt, spacerColor);
if (verticalInsets.bottomDecoration) {
appendContainerDecorationParagraph(
cell,
verticalInsets.bottomDecoration.heightPt,
verticalInsets.bottomDecoration.color
);
}
return true;
}
@@ -692,11 +781,23 @@ function createEditableContainerTable(
) {
const document = body.ownerDocument!;
const widthPercent = token.widthPercent ?? 100;
const outlineInsetPt = token.outline
? Math.max(0, -(token.outlineOffsetPt ?? 0))
: 0;
const outlineCellSpacingPt = outlineInsetPt / 2;
const tableWidth = Math.max(
1,
Math.round((contentWidthTwips * widthPercent) / 100)
);
const table = document.createElementNS(WORD_NAMESPACE, "w:tbl");
const topDecorationPt =
(token.borders?.top?.widthPt ?? 0) > MAX_WORD_BORDER_WIDTH_PT
? token.borders!.top!.widthPt
: 0;
const bottomDecorationPt =
(token.borders?.bottom?.widthPt ?? 0) > MAX_WORD_BORDER_WIDTH_PT
? token.borders!.bottom!.widthPt
: 0;
const tableProperties = appendElement(
table,
WORD_NAMESPACE,
@@ -707,12 +808,24 @@ function createEditableContainerTable(
"w:type": "dxa"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblInd", {
"w:w": String(pointsToTwips(token.leftIndentPt ?? 0)),
"w:w": String(
pointsToTwips(
(token.leftIndentPt ?? 0) +
outlineInsetPt +
(token.outline ? token.borders?.left?.widthPt ?? 0 : 0)
)
),
"w:type": "dxa"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
if (outlineCellSpacingPt > 0) {
appendElement(tableProperties, WORD_NAMESPACE, "w:tblCellSpacing", {
"w:w": String(pointsToTwips(outlineCellSpacingPt)),
"w:type": "dxa"
});
}
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLook", {
"w:val": "0000",
"w:firstRow": "0",
@@ -743,7 +856,11 @@ function createEditableContainerTable(
const border =
side === "insideH" || side === "insideV"
? undefined
: token.borders?.[side];
: side === "top" && topDecorationPt > 0
? undefined
: side === "bottom" && bottomDecorationPt > 0
? undefined
: token.borders?.[side];
appendElement(
borders,
WORD_NAMESPACE,
@@ -763,6 +880,7 @@ function createEditableContainerTable(
);
appendElement(rowProperties, WORD_NAMESPACE, "w:cantSplit");
let contentMinimumHeightPt: number | undefined;
let minimumHeightWasBounded = false;
let collapsesVerticalPadding = false;
if (token.minimumHeightPt !== undefined) {
// A semantic cover followed by its own section must use an exact row:
@@ -772,7 +890,12 @@ function createEditableContainerTable(
collapsesVerticalPadding = true;
const boundedMinimumHeightPt = Math.min(
token.minimumHeightPt,
contentHeightTwips / 20
Math.max(1, contentHeightTwips / 20 - COVER_PAGE_BREAK_SAFETY_PT)
);
minimumHeightWasBounded = boundedMinimumHeightPt < token.minimumHeightPt;
const renderedMinimumHeightPt = Math.max(
1,
boundedMinimumHeightPt - outlineInsetPt
);
const verticalPaddingPt =
collapsesVerticalPadding
@@ -784,20 +907,20 @@ function createEditableContainerTable(
(token.borders?.bottom?.widthPt ?? 0);
contentMinimumHeightPt = Math.max(
1,
boundedMinimumHeightPt - verticalPaddingPt - verticalBorderPt
renderedMinimumHeightPt - verticalPaddingPt - verticalBorderPt
);
appendElement(rowProperties, WORD_NAMESPACE, "w:trHeight", {
"w:val": String(
pointsToTwips(
collapsesVerticalPadding
? boundedMinimumHeightPt
? renderedMinimumHeightPt
: contentMinimumHeightPt
)
),
"w:hRule": collapsesVerticalPadding ? "exact" : "atLeast"
});
if (collapsesVerticalPadding) {
contentMinimumHeightPt = boundedMinimumHeightPt;
contentMinimumHeightPt = renderedMinimumHeightPt;
}
}
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
@@ -822,6 +945,21 @@ function createEditableContainerTable(
"w:fill": colorValue(token.backgroundColor)
});
}
if (token.outline) {
const cellBorders = appendElement(
cellProperties,
WORD_NAMESPACE,
"w:tcBorders"
);
for (const side of ["top", "left", "bottom", "right"] as const) {
appendElement(
cellBorders,
WORD_NAMESPACE,
`w:${side}`,
borderAttributes(token.outline)
);
}
}
if (token.paddingPt) {
const margins = appendElement(
cellProperties,
@@ -836,7 +974,7 @@ function createEditableContainerTable(
? side === "left" || side === "right"
? 1
: 0
: token.paddingPt[side] ?? 0
: Math.max(0, (token.paddingPt[side] ?? 0) - outlineInsetPt)
);
}
}
@@ -844,7 +982,8 @@ function createEditableContainerTable(
1,
tableWidth -
pointsToTwips(token.paddingPt?.left ?? 0) -
pointsToTwips(token.paddingPt?.right ?? 0)
pointsToTwips(token.paddingPt?.right ?? 0) +
pointsToTwips(outlineInsetPt * 2)
);
const childParagraphs = elements.filter(
(element) =>
@@ -895,10 +1034,14 @@ function createEditableContainerTable(
childToken.selfAlignment ?? token.childAlignment;
const properties = paragraphProperties(element);
const containerLeftIndent = collapsesVerticalPadding
? pointsToTwips(token.paddingPt?.left ?? 0)
? pointsToTwips(
Math.max(0, (token.paddingPt?.left ?? 0) - outlineInsetPt)
)
: 0;
const containerRightIndent = collapsesVerticalPadding
? pointsToTwips(token.paddingPt?.right ?? 0)
? pointsToTwips(
Math.max(0, (token.paddingPt?.right ?? 0) - outlineInsetPt)
)
: 0;
if (!childAlignment || childAlignment === "stretch") {
if (containerLeftIndent > 0 || containerRightIndent > 0) {
@@ -927,15 +1070,29 @@ function createEditableContainerTable(
innerWidthTwips,
Math.round(
(innerWidthTwips * minimumWidthPercent) / 100
)
) +
pointsToTwips(
(childToken.fontSizePt ?? 12) * 0.6
)
)
: childToken.selfAlignment &&
childToken.widthPercent !== undefined &&
childToken.widthPercent < 100
? Math.min(
innerWidthTwips,
Math.round(
(innerWidthTwips * childToken.widthPercent) / 100
Math.max(
Math.round(
(innerWidthTwips * childToken.widthPercent) / 100
) +
pointsToTwips(
(childToken.fontSizePt ?? 12) * 0.6
),
estimatedParagraphWidthTwips(element, childToken) +
pointsToTwips(
(childToken.paddingPt?.left ?? 0) +
(childToken.paddingPt?.right ?? 0) +
(childToken.fontSizePt ?? 12)
)
)
)
: hasVisibleBorder(childToken) || childToken.backgroundColor
@@ -944,7 +1101,10 @@ function createEditableContainerTable(
estimatedParagraphWidthTwips(element, childToken) +
pointsToTwips(
(childToken.paddingPt?.left ?? 0) +
(childToken.paddingPt?.right ?? 0)
(childToken.paddingPt?.right ?? 0) +
(childAlignment === "center"
? childToken.fontSizePt ?? 12
: 0)
)
)
: undefined;
@@ -1020,7 +1180,70 @@ function createEditableContainerTable(
row,
elements,
paragraphTokens,
contentMinimumHeightPt,
Math.max(
1,
contentMinimumHeightPt -
(minimumHeightWasBounded
? OFFICE_END_OF_CELL_SAFETY_PT -
COVER_PAGE_BREAK_SAFETY_PT
: 0)
),
{
topPt: Math.max(
0,
(token.paddingPt?.top ?? 0) - outlineInsetPt
),
bottomPt: Math.max(
0,
(token.paddingPt?.bottom ?? 0) - outlineInsetPt
),
borderPt:
(topDecorationPt > 0 ? 0 : token.borders?.top?.widthPt ?? 0) +
(bottomDecorationPt > 0
? 0
: token.borders?.bottom?.widthPt ?? 0),
// Tight landscape covers already account for a normal top border in
// their padding budget. A roomier constrained cover with a strongly
// asymmetric top border still needs the Office flow-start allowance;
// otherwise its centered content crosses the 6pt cover tolerance.
flowStartSafetyPt:
minimumHeightWasBounded &&
contentMinimumHeightPt >= 500 &&
topDecorationPt === 0 &&
(token.borders?.top?.widthPt ?? 0) >
(token.borders?.bottom?.widthPt ?? 0) + 1
? token.borders?.top?.widthPt ?? 0
: 0,
centerBiasPt:
COVER_CONTENT_CENTER_BIAS_PT +
(topDecorationPt > 0 || bottomDecorationPt > 0 ? 3 : 0) -
outlineInsetPt * 0.6 +
(minimumHeightWasBounded && token.outline ? 3 : 0),
// An inset outline is represented by a second editable cell border.
// Keep the Office end-of-cell budget for that nested border instead
// of releasing it back into compressed child spacing.
preserveTerminalSafety: Boolean(token.outline),
...(topDecorationPt > 0
? {
topDecoration: {
heightPt:
topDecorationPt *
OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR,
color: colorValue(token.borders!.top!.color)
}
}
: {}),
...(bottomDecorationPt > 0
? {
bottomDecoration: {
heightPt:
bottomDecorationPt *
OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR,
color: colorValue(token.borders!.bottom!.color)
}
}
: {})
},
distinctLayoutSpacerColor(
token.backgroundColor ? colorValue(token.backgroundColor) : "FFFFFF"
)
@@ -1318,12 +1541,14 @@ function applySections(
preceding.localName === "p"
? preceding
: undefined;
let syntheticSectionCarrier = false;
if (!previous) {
previous = body.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:p"
);
body.insertBefore(previous, marker);
syntheticSectionCarrier = true;
}
const coverSection = followingSection.cloneNode(
true
@@ -1357,6 +1582,28 @@ function applySections(
});
}
const properties = paragraphProperties(previous);
if (syntheticSectionCarrier) {
const spacing = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:spacing"
);
setWordAttribute(spacing, "before", "0");
setWordAttribute(spacing, "after", "0");
setWordAttribute(spacing, "line", "1");
setWordAttribute(spacing, "lineRule", "exact");
const runProperties = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:rPr"
);
appendElement(runProperties, WORD_NAMESPACE, "w:sz", {
"w:val": "2"
});
appendElement(runProperties, WORD_NAMESPACE, "w:szCs", {
"w:val": "2"
});
}
removeDirectChildren(properties, WORD_NAMESPACE, "sectPr");
properties.appendChild(coverSection);
setSectionPageNumber(
@@ -438,14 +438,14 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1256" w:right="1275"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t><\/w:t>/u
);
expect(documentXml).toContain('<w:tblLook w:val="0000"');
expect(documentXml).toMatch(
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
/<\/w:tbl><w:p><w:pPr>[\s\S]*?<w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"\/>[\s\S]*?<w:sectPr>/u
);
expect(
documentXml.match(/<w:tbl(?:\s|>)/gu)
@@ -458,6 +458,47 @@ describe("生成 DOCX 结构收口", () => {
});
});
it("将封面 border 与内缩 outline 映射为双层可编辑容器边框", () => {
const outlinedTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 620,
verticalAlignment: "center" as const,
outline: {
widthPt: 0.75,
style: "single" as const,
color: "#777777"
},
outlineOffsetPt: -12
}
}
: entry
)
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
outlinedTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblCellSpacing w:w="120" w:type="dxa"/>'
);
expect(documentXml).toMatch(
/<w:tcBorders><w:top w:val="single" w:sz="6" w:space="0" w:color="777777"\/><w:left[\s\S]*?<w:bottom[\s\S]*?<w:right/u
);
});
it("将封面最小高度封顶到当前节的页面内容区", () => {
const oversizedTokens: DocxThemeTokenSet = {
...tokens,
@@ -469,7 +510,15 @@ describe("生成 DOCX 结构收口", () => {
...entry.style,
minimumHeightPt: 900,
verticalAlignment: "center" as const,
childAlignment: "left" as const
childAlignment: "left" as const,
borders: {
...entry.style.borders,
top: {
widthPt: 20,
style: "single" as const,
color: "#111111"
}
}
}
}
: entry
@@ -508,13 +557,19 @@ describe("生成 DOCX 结构收口", () => {
);
expect(documentXml).toContain(
'<w:trHeight w:val="14838" w:hRule="exact"/>'
'<w:trHeight w:val="14718" w:hRule="exact"/>'
);
expect(documentXml).toContain(
'<w:shd w:val="clear" w:color="auto" w:fill="111111"/>'
);
expect(documentXml).toContain(
'<w:spacing w:before="0" w:after="0" w:line="500" w:lineRule="exact"/>'
);
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});