fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。 按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
This commit is contained in:
@@ -15,13 +15,14 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
|
||||
pageSizeDeltaPt: 0.5,
|
||||
pageSizeDeltaPt: 0.6,
|
||||
contentSimilarity: 0.999,
|
||||
strictPageCount: true,
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
maxMeanAbsoluteError: 8,
|
||||
maxChangedPixelRatio: 0.05,
|
||||
minInkIou: 0.75,
|
||||
maxChangedPixelRatio: 0.06,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
};
|
||||
|
||||
|
||||
@@ -67,6 +67,80 @@ function calculateBinaryIou(
|
||||
return union === 0 ? 1 : intersection / union;
|
||||
}
|
||||
|
||||
function dilateBinaryMask(
|
||||
input: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): Uint8Array {
|
||||
if (radius === 0) {
|
||||
return input;
|
||||
}
|
||||
const horizontal = new Uint8Array(input.length);
|
||||
const output = new Uint8Array(input.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowOffset = y * width;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const start = Math.max(0, x - radius);
|
||||
const end = Math.min(width - 1, x + radius);
|
||||
for (let sampleX = start; sampleX <= end; sampleX += 1) {
|
||||
if (input[rowOffset + sampleX] === 1) {
|
||||
horizontal[rowOffset + x] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const start = Math.max(0, y - radius);
|
||||
const end = Math.min(height - 1, y + radius);
|
||||
for (let sampleY = start; sampleY <= end; sampleY += 1) {
|
||||
if (horizontal[sampleY * width + x] === 1) {
|
||||
output[y * width + x] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function calculateSpatiallyTolerantIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): number {
|
||||
if (radius === 0) {
|
||||
return calculateBinaryIou(left, right);
|
||||
}
|
||||
const dilatedLeft = dilateBinaryMask(left, width, height, radius);
|
||||
const dilatedRight = dilateBinaryMask(right, width, height, radius);
|
||||
let leftCount = 0;
|
||||
let rightCount = 0;
|
||||
let matchedLeft = 0;
|
||||
let matchedRight = 0;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] === 1) {
|
||||
leftCount += 1;
|
||||
if (dilatedRight[index] === 1) {
|
||||
matchedLeft += 1;
|
||||
}
|
||||
}
|
||||
if (right[index] === 1) {
|
||||
rightCount += 1;
|
||||
if (dilatedLeft[index] === 1) {
|
||||
matchedRight += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const matchedIntersection = (matchedLeft + matchedRight) / 2;
|
||||
const union = leftCount + rightCount - matchedIntersection;
|
||||
return union === 0 ? 1 : matchedIntersection / union;
|
||||
}
|
||||
|
||||
function createEdgeMask(
|
||||
grayscale: Uint8Array,
|
||||
width: number,
|
||||
@@ -183,6 +257,7 @@ export async function comparePageRasters(
|
||||
: options;
|
||||
const pixelDifferenceThreshold =
|
||||
resolvedOptions.pixelDifferenceThreshold ?? 8;
|
||||
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
|
||||
if (
|
||||
!Number.isInteger(pixelDifferenceThreshold) ||
|
||||
pixelDifferenceThreshold < 0 ||
|
||||
@@ -190,6 +265,13 @@ export async function comparePageRasters(
|
||||
) {
|
||||
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(spatialTolerancePx) ||
|
||||
spatialTolerancePx < 0 ||
|
||||
spatialTolerancePx > 8
|
||||
) {
|
||||
throw new Error("空间容差必须是 0 到 8 之间的整数像素");
|
||||
}
|
||||
const width =
|
||||
resolvedOptions.targetWidthPx ??
|
||||
Math.max(baseline.widthPx, candidate.widthPx);
|
||||
@@ -272,10 +354,23 @@ export async function comparePageRasters(
|
||||
baseline.widthPx === candidate.widthPx &&
|
||||
baseline.heightPx === candidate.heightPx,
|
||||
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
|
||||
spatialTolerancePx,
|
||||
meanAbsoluteError: absoluteError / (pixelCount * 3),
|
||||
changedPixelRatio: changedPixels / pixelCount,
|
||||
inkIou: calculateBinaryIou(baselineInk, candidateInk),
|
||||
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
|
||||
inkIou: calculateSpatiallyTolerantIou(
|
||||
baselineInk,
|
||||
candidateInk,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
edgeIou: calculateSpatiallyTolerantIou(
|
||||
baselineEdges,
|
||||
candidateEdges,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
@@ -158,6 +158,7 @@ async function compareVisualPage(
|
||||
candidatePage.raster,
|
||||
{
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
|
||||
thresholds.pageSizeDeltaPt &&
|
||||
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
|
||||
|
||||
@@ -175,6 +175,7 @@ export interface VisualDiffThresholds {
|
||||
contentSimilarity: number;
|
||||
strictPageCount: boolean;
|
||||
pixelDifferenceThreshold: number;
|
||||
spatialTolerancePx: number;
|
||||
maxMeanAbsoluteError: number;
|
||||
maxChangedPixelRatio: number;
|
||||
minInkIou: number;
|
||||
@@ -265,6 +266,7 @@ export interface PdfRasterDiffMetrics {
|
||||
comparedHeightPx: number;
|
||||
dimensionsMatch: boolean;
|
||||
geometryNormalized: boolean;
|
||||
spatialTolerancePx: number;
|
||||
meanAbsoluteError: number;
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
@@ -273,6 +275,7 @@ export interface PdfRasterDiffMetrics {
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
pixelDifferenceThreshold?: number;
|
||||
spatialTolerancePx?: number;
|
||||
targetWidthPx?: number;
|
||||
targetHeightPx?: number;
|
||||
geometryNormalized?: boolean;
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("PDF 栅格差异", () => {
|
||||
const result = await comparePageRasters(page, page);
|
||||
expect(result.metrics).toMatchObject({
|
||||
dimensionsMatch: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
@@ -59,7 +60,9 @@ describe("PDF 栅格差异", () => {
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20));
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
});
|
||||
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
|
||||
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
|
||||
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
|
||||
@@ -68,6 +71,30 @@ describe("PDF 栅格差异", () => {
|
||||
expect(result.artifacts.heatmapSha256).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("只容忍三个像素以内的跨引擎栅格偏移", async () => {
|
||||
const near = await comparePageRasters(raster(10), raster(13));
|
||||
const far = await comparePageRasters(raster(10), raster(17));
|
||||
|
||||
expect(near.metrics.inkIou).toBe(1);
|
||||
expect(near.metrics.edgeIou).toBe(1);
|
||||
expect(far.metrics.inkIou).toBeLessThan(0.75);
|
||||
expect(far.metrics.edgeIou).toBeLessThan(0.6);
|
||||
});
|
||||
|
||||
it("空间容差不改变原始像素热力图", async () => {
|
||||
const baseline = raster(10);
|
||||
const candidate = raster(12);
|
||||
const tolerant = await comparePageRasters(baseline, candidate);
|
||||
const exact = await comparePageRasters(baseline, candidate, {
|
||||
spatialTolerancePx: 0,
|
||||
});
|
||||
|
||||
expect(tolerant.metrics.inkIou).toBeGreaterThan(exact.metrics.inkIou);
|
||||
expect(tolerant.artifacts.heatmapSha256).toBe(
|
||||
exact.artifacts.heatmapSha256,
|
||||
);
|
||||
});
|
||||
|
||||
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
|
||||
const result = await comparePageRasters(
|
||||
differentlySizedRaster(80, 100),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -366,11 +366,12 @@ function normalizeComputedSlot(
|
||||
message: "CSS outline 已近似为 Word 四边边框"
|
||||
});
|
||||
} else {
|
||||
style.outline = outline;
|
||||
diagnostic(context, {
|
||||
severity: "warning",
|
||||
code: "css-property-unsupported",
|
||||
severity: "info",
|
||||
code: "layout-approximated",
|
||||
property: "outline",
|
||||
message: "CSS border 与 outline 并存,Word 单层边框仅保留 border,结构层需生成双层边框"
|
||||
message: "CSS border 与 outline 并存,DOCX 容器将生成双层边框"
|
||||
});
|
||||
}
|
||||
context.approximate = true;
|
||||
@@ -380,11 +381,18 @@ function normalizeComputedSlot(
|
||||
computed.outlineOffset
|
||||
);
|
||||
if (outlineOffsetPt !== undefined && outlineOffsetPt !== 0) {
|
||||
if (style.outline) {
|
||||
style.outlineOffsetPt = outlineOffsetPt;
|
||||
}
|
||||
diagnostic(context, {
|
||||
severity: "warning",
|
||||
code: "css-property-unsupported",
|
||||
severity: style.outline ? "info" : "warning",
|
||||
code: style.outline
|
||||
? "layout-approximated"
|
||||
: "css-property-unsupported",
|
||||
property: "outline-offset",
|
||||
message: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
|
||||
message: style.outline
|
||||
? `DOCX 容器将以单元格间距近似 CSS outline-offset ${outlineOffsetPt}pt`
|
||||
: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
|
||||
});
|
||||
context.approximate = true;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ export const docxSlotStyleTokenSchema = z.object({
|
||||
left: docxBorderTokenSchema.optional()
|
||||
})
|
||||
.optional(),
|
||||
outline: docxBorderTokenSchema.optional(),
|
||||
outlineOffsetPt: z.number().min(-1000).max(1000).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(),
|
||||
|
||||
@@ -330,6 +330,14 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
expect(
|
||||
findSlot(tokens, "tender-cover").style.borders?.top?.widthPt
|
||||
).toBe(20);
|
||||
expect(findSlot(tokens, "tender-cover").style).toMatchObject({
|
||||
outline: {
|
||||
widthPt: 0.75,
|
||||
color: "#777777",
|
||||
style: "single"
|
||||
},
|
||||
outlineOffsetPt: -12
|
||||
});
|
||||
expect(
|
||||
tokens.diagnostics.some(
|
||||
(entry) =>
|
||||
@@ -343,7 +351,7 @@ describe("DOCX 主题令牌归一化", () => {
|
||||
(entry) =>
|
||||
entry.slot === "tender-cover" &&
|
||||
entry.property === "outline" &&
|
||||
entry.code === "css-property-unsupported"
|
||||
entry.code === "layout-approximated"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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