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