Skip to content

Commit 70a2dbe

Browse files
fix: correct spacing for paragraphs with borders/shades (#2586)
* fix: correct spacing for paragraphs with borders/shades * fix(layout): overlap border expansion with paragraph spacing (SD-2106) Per ECMA-376 §17.3.1.42, paragraph border space is measured from the text edge "ignoring any spacing above/below". This means border expansion should overlap with spacingBefore/After, not stack on top. - Reduce top border expansion by the amount spacingBefore already covers - Reduce effective spacingAfter by the bottom border expansion - Subtract border expansion from sliceLines available height to prevent page-bottom overflow - Include border expansion in keepLines height calculation * fix(layout): correct cursorY mismatch and continuation fragment overlap Three fixes from self-review: - fragment.y now uses extraTop (overlap-adjusted) instead of full borderExpansion.top, so cursorY reaches the visual bottom exactly - Track spacingAppliedThisFragment to avoid under-counting border expansion on continuation fragments after page breaks - keepLines fitsOnBlankPage uses overlap model (max instead of add) to avoid unnecessary page breaks for bordered paragraphs * fix(layout): hoist borderExpansion to function scope borderExpansion was declared inside the while loop but referenced in the spacingAfter block outside it, causing a ReferenceError. Move it before the loop so it's accessible in both places. * fix(layout,renderer): between-border group detection and spacing Layout engine: - Detect between-border groups by comparing adjacent paragraph border hashes. When borders match, suppress top expansion and reclaim previous paragraph's bottom expansion. - Border expansion is additive with spacing for non-grouped paragraphs (matches Word behavior). - Add lastParagraphBorderHash to PageState for group tracking. Renderer (group-analysis.ts): - Remove requirement for `between` border element to be present for grouping. Per ECMA-376 §17.3.1.5, grouping occurs when all border properties are identical — a between border is optional. - When no between border is defined, suppress bottom/top borders and render as a single continuous box. * test(painter-dom): update between-border test for grouping without between element The test expected no grouping when `between` border is absent. Updated to expect grouping with suppressBottomBorder per ECMA-376 §17.3.1.5: identical borders form a group regardless of whether a between border is defined. * fix(layout): use local PX_PER_PT constant instead of pm-adapter import layout-engine cannot import from pm-adapter (boundary removed in #2618). Define PX_PER_PT locally, same as border-layer.ts does. --------- Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
1 parent 357e593 commit 70a2dbe

4 files changed

Lines changed: 96 additions & 15 deletions

File tree

packages/layout-engine/layout-engine/src/layout-paragraph.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
DrawingBlock,
1313
DrawingMeasure,
1414
DrawingFragment,
15+
ParagraphBorders,
1516
} from '@superdoc/contracts';
1617
import {
1718
computeFragmentPmRange,
@@ -24,6 +25,9 @@ import {
2425
import { computeAnchorX } from './floating-objects.js';
2526
import { getFragmentZIndex } from '@superdoc/contracts';
2627

28+
/** Points → CSS pixels (96 dpi / 72 pt-per-inch). */
29+
const PX_PER_PT = 96 / 72;
30+
2731
const spacingDebugEnabled = false;
2832
/**
2933
* Type definition for Word layout attributes attached to paragraph blocks.
@@ -89,6 +93,8 @@ type ParagraphBlockAttrs = {
8993
floatAlignment?: unknown;
9094
/** Keep all lines of the paragraph on the same page */
9195
keepLines?: boolean;
96+
/** Border attributes for the paragraph */
97+
borders?: ParagraphBorders;
9298
};
9399

94100
const spacingDebugLog = (..._args: unknown[]): void => {
@@ -162,6 +168,39 @@ const asSafeNumber = (value: unknown): number => {
162168
return value;
163169
};
164170

171+
/**
172+
* Simple hash of paragraph borders for between-border group detection.
173+
* Two paragraphs form a group when their border hashes match (ECMA-376 §17.3.1.5).
174+
*/
175+
const hashBorders = (borders?: ParagraphBorders): string | undefined => {
176+
if (!borders) return undefined;
177+
const side = (b?: { style?: string; width?: number; color?: string; space?: number }) =>
178+
b ? `${b.style ?? ''},${b.width ?? 0},${b.color ?? ''},${b.space ?? 0}` : '';
179+
return `${side(borders.top)}|${side(borders.right)}|${side(borders.bottom)}|${side(borders.left)}|${side(borders.between)}`;
180+
};
181+
182+
/**
183+
* Computes the vertical border expansion for a paragraph fragment.
184+
* The border's `space` attribute (in points) plus the border width extends
185+
* the visual box beyond the content area. This ensures cursorY accounts
186+
* for the full visual height when paragraphs have borders with space.
187+
*/
188+
const computeBorderVerticalExpansion = (borders?: ParagraphBorders): { top: number; bottom: number } => {
189+
if (!borders) return { top: 0, bottom: 0 };
190+
191+
// Top border: space (pts) + width (px)
192+
const topSpace = (borders.top?.space ?? 0) * PX_PER_PT;
193+
const topWidth = borders.top?.width ?? 0;
194+
const top = topSpace + topWidth;
195+
196+
// Bottom border: space (pts) + width (px)
197+
const bottomSpace = (borders.bottom?.space ?? 0) * PX_PER_PT;
198+
const bottomWidth = borders.bottom?.width ?? 0;
199+
const bottom = bottomSpace + bottomWidth;
200+
201+
return { top, bottom };
202+
};
203+
165204
/**
166205
* Calculates the first line indent for list markers when remeasuring paragraphs.
167206
*
@@ -589,11 +628,33 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
589628
}
590629
}
591630

631+
// Compute border expansion once per paragraph (constant across fragments).
632+
// Border space overlaps with paragraph spacing per ECMA-376 §17.3.1.42:
633+
// "the space above the text (ignoring any spacing above)"
634+
const rawBorderExpansion = computeBorderVerticalExpansion(attrs?.borders);
635+
636+
// Between-border group detection (ECMA-376 §17.3.1.5): when adjacent paragraphs
637+
// have identical borders, they form a group — top/bottom borders are suppressed
638+
// between group members, so the layout engine should not reserve space for them.
639+
const currentBorderHash = hashBorders(attrs?.borders);
640+
const inBorderGroup = currentBorderHash != null && currentBorderHash === ensurePage().lastParagraphBorderHash;
641+
const borderExpansion = {
642+
top: inBorderGroup ? 0 : rawBorderExpansion.top,
643+
bottom: rawBorderExpansion.bottom, // bottom suppression is handled when the NEXT paragraph joins the group
644+
};
645+
592646
// PHASE 2: Layout the paragraph with the remeasured lines
593647
while (fromLine < lines.length) {
594648
let state = ensurePage();
595649
if (state.trailingSpacing == null) state.trailingSpacing = 0;
596650

651+
// Reclaim the previous paragraph's bottom border expansion when joining a group.
652+
// The previous paragraph already reserved space for its bottom border, but in a
653+
// group that border is suppressed — so we move cursorY back to close the gap.
654+
if (inBorderGroup && fromLine === 0) {
655+
state.cursorY -= rawBorderExpansion.bottom;
656+
}
657+
597658
/**
598659
* Contextual Spacing Logic (OOXML w:contextualSpacing)
599660
*
@@ -635,12 +696,14 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
635696
* We use baseSpacingBefore for the blank page check because on a new page there's no
636697
* previous trailing spacing to collapse with.
637698
*/
699+
638700
const keepLines = attrs?.keepLines === true;
639701
if (keepLines && fromLine === 0) {
640702
const prevTrailing = state.trailingSpacing ?? 0;
641703
const neededSpacingBefore = Math.max(spacingBefore - prevTrailing, 0);
642704
const pageContentHeight = state.contentBottom - state.topMargin;
643-
const fullHeight = lines.reduce((sum, line) => sum + (line.lineHeight || 0), 0);
705+
const linesHeight = lines.reduce((sum, line) => sum + (line.lineHeight || 0), 0);
706+
const fullHeight = linesHeight + borderExpansion.top + borderExpansion.bottom;
644707
const fitsOnBlankPage = fullHeight + baseSpacingBefore <= pageContentHeight;
645708
const remainingHeightAfterSpacing = state.contentBottom - (state.cursorY + neededSpacingBefore);
646709
if (fitsOnBlankPage && state.page.fragments.length > 0 && fullHeight > remainingHeightAfterSpacing) {
@@ -774,7 +837,11 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
774837
offsetX = narrowestOffsetX;
775838
}
776839

777-
const slice = sliceLines(lines, fromLine, state.contentBottom - state.cursorY);
840+
// Reserve border expansion from available height so sliceLines doesn't accept
841+
// lines that would overflow the page once border space is added.
842+
const borderVertical = borderExpansion.top + borderExpansion.bottom;
843+
const availableForSlice = Math.max(0, state.contentBottom - state.cursorY - borderVertical);
844+
const slice = sliceLines(lines, fromLine, availableForSlice);
778845
const fragmentHeight = slice.height;
779846

780847
// Apply negative indent adjustment to fragment position and width (similar to table indent handling).
@@ -785,14 +852,13 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
785852
// Expand width: negative indents on both sides expand the fragment width
786853
// (e.g., -48px left + -72px right = 120px wider)
787854
const adjustedWidth = effectiveColumnWidth - negativeLeftIndent - negativeRightIndent;
788-
789855
const fragment: ParaFragment = {
790856
kind: 'para',
791857
blockId: block.id,
792858
fromLine,
793859
toLine: slice.toLine,
794860
x: adjustedX,
795-
y: state.cursorY,
861+
y: state.cursorY + borderExpansion.top,
796862
width: adjustedWidth,
797863
...computeFragmentPmRange(block, lines, fromLine, slice.toLine),
798864
};
@@ -838,9 +904,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
838904
fragment.x = columnX(state.columnIndex) + offsetX + (effectiveColumnWidth - maxLineWidth) / 2;
839905
}
840906
}
841-
842907
state.page.fragments.push(fragment);
843-
state.cursorY += fragmentHeight;
908+
909+
state.cursorY += borderExpansion.top + fragmentHeight + borderExpansion.bottom;
844910
lastState = state;
845911
fromLine = slice.toLine;
846912
}
@@ -879,5 +945,6 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
879945
}
880946
lastState.lastParagraphStyleId = styleId;
881947
lastState.lastParagraphContextualSpacing = contextualSpacing;
948+
lastState.lastParagraphBorderHash = currentBorderHash;
882949
}
883950
}

packages/layout-engine/layout-engine/src/paginator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type PageState = {
1818
trailingSpacing: number;
1919
lastParagraphStyleId?: string;
2020
lastParagraphContextualSpacing: boolean;
21+
/** Border hash of the last paragraph for between-border group detection. */
22+
lastParagraphBorderHash?: string;
2123
};
2224

2325
export type PaginatorOptions = {

packages/layout-engine/painters/dom/src/between-borders.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,14 +469,22 @@ describe('computeBetweenBorderFlags', () => {
469469
expect(flags.size).toBe(2);
470470
});
471471

472-
it('does not flag when between border is not defined', () => {
472+
it('groups identical borders even when between border is not defined', () => {
473+
// ECMA-376 §17.3.1.5: grouping occurs when all border properties are identical.
474+
// When no between border is defined, the group renders as a single box (no separator).
473475
const noBetween: ParagraphBorders = { top: { style: 'solid', width: 1 } };
474476
const b1 = makeParagraphBlock('b1', noBetween);
475477
const b2 = makeParagraphBlock('b2', noBetween);
476478
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
477479
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];
478480

479-
expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
481+
const flags = computeBetweenBorderFlags(fragments, lookup);
482+
expect(flags.size).toBe(2);
483+
// First fragment: bottom border suppressed (no between separator, single box)
484+
expect(flags.get(0)?.suppressBottomBorder).toBe(true);
485+
expect(flags.get(0)?.showBetweenBorder).toBe(false);
486+
// Second fragment: top border suppressed
487+
expect(flags.get(1)?.suppressTopBorder).toBe(true);
480488
});
481489

482490
it('does not flag when border definitions do not match', () => {

packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,13 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => {
108108
* 1. Both are para or list-item (not table/image/drawing)
109109
* 2. Neither is a page-split continuation
110110
* 3. They represent different logical paragraphs
111-
* 4. Both have a `between` border defined (not nil/none)
111+
* 4. Both have border definitions
112112
* 5. Their full border definitions match (same border group)
113113
*
114+
* Per ECMA-376 §17.3.1.5: grouping occurs when all border properties are
115+
* identical. A `between` border is NOT required — when absent, the group
116+
* is rendered as a single box without a separator line.
117+
*
114118
* For each pair, the first fragment gets:
115119
* - showBetweenBorder: true — bottom border replaced with between definition
116120
* - gapBelow: px distance to extend border layer into spacing gap
@@ -134,9 +138,7 @@ export const computeBetweenBorderFlags = (
134138
if (frag.continuesOnNext) continue;
135139

136140
const borders = getFragmentParagraphBorders(frag, blockLookup);
137-
// Skip if no between element at all (no grouping intent).
138-
// between: {style: 'none'} (nil/none) IS allowed — it signals grouping without a separator.
139-
if (!borders?.between) continue;
141+
if (!borders) continue;
140142

141143
const next = fragments[i + 1];
142144
if (next.kind !== 'para' && next.kind !== 'list-item') continue;
@@ -151,15 +153,17 @@ export const computeBetweenBorderFlags = (
151153
continue;
152154

153155
const nextBorders = getFragmentParagraphBorders(next, blockLookup);
154-
if (!nextBorders?.between) continue;
155-
if (hashParagraphBorders(borders!) !== hashParagraphBorders(nextBorders!)) continue;
156+
if (!nextBorders) continue;
157+
if (hashParagraphBorders(borders) !== hashParagraphBorders(nextBorders)) continue;
156158

157159
// Skip fragments in different columns (different x positions)
158160
if (frag.x !== next.x) continue;
159161

160162
pairFlags.add(i);
161163

162-
// Track nil/none between pairs — these get suppressBottomBorder instead of showBetweenBorder
164+
// Track nil/none/absent between pairs — these get suppressBottomBorder instead of showBetweenBorder.
165+
// Per ECMA-376 §17.3.1.5: grouping happens when ALL borders are identical.
166+
// When no between border is defined, the group has no separator line.
163167
if (isBetweenBorderNone(borders) && isBetweenBorderNone(nextBorders)) {
164168
noBetweenPairs.add(i);
165169
}

0 commit comments

Comments
 (0)