Skip to content

Commit 23aa391

Browse files
committed
feat: apply paragraph border space as padding between border and text
The OOXML `space` attribute on w:pBdr sides specifies the distance between the border line and the paragraph text (in points). Word uses this to create visible padding inside bordered paragraphs. - Add computeBorderSpaceExpansion() to convert space values to px - Expand the border/shading layers outward by the space amount - Suppress top/bottom space within between-border groups where those sides are already handled by gap extension
1 parent ca3b9d5 commit 23aa391

3 files changed

Lines changed: 124 additions & 5 deletions

File tree

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
computeBetweenBorderFlags,
66
createParagraphDecorationLayers,
77
getParagraphBorderBox,
8+
computeBorderSpaceExpansion,
89
type BlockLookup,
910
type BetweenBorderInfo,
1011
} from './features/paragraph-borders/index.js';
@@ -884,6 +885,86 @@ describe('getParagraphBorderBox', () => {
884885
});
885886
});
886887

888+
// ---------------------------------------------------------------------------
889+
// computeBorderSpaceExpansion — border space (padding between border and text)
890+
// ---------------------------------------------------------------------------
891+
892+
describe('computeBorderSpaceExpansion', () => {
893+
const PX_PER_PT = 96 / 72;
894+
895+
it('returns zero expansion when no borders', () => {
896+
expect(computeBorderSpaceExpansion(undefined)).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
897+
});
898+
899+
it('returns zero expansion when borders have no space', () => {
900+
const borders: ParagraphBorders = {
901+
top: { style: 'solid', width: 1 },
902+
bottom: { style: 'solid', width: 1 },
903+
};
904+
expect(computeBorderSpaceExpansion(borders)).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
905+
});
906+
907+
it('expands all sides by space in px', () => {
908+
const borders: ParagraphBorders = {
909+
top: { style: 'solid', width: 1, space: 2 },
910+
bottom: { style: 'solid', width: 1, space: 3 },
911+
left: { style: 'solid', width: 1, space: 1 },
912+
right: { style: 'solid', width: 1, space: 4 },
913+
};
914+
const result = computeBorderSpaceExpansion(borders);
915+
expect(result.top).toBeCloseTo(2 * PX_PER_PT);
916+
expect(result.bottom).toBeCloseTo(3 * PX_PER_PT);
917+
expect(result.left).toBeCloseTo(1 * PX_PER_PT);
918+
expect(result.right).toBeCloseTo(4 * PX_PER_PT);
919+
});
920+
921+
it('suppresses top expansion when suppressTopBorder is true', () => {
922+
const borders: ParagraphBorders = {
923+
top: { style: 'solid', width: 1, space: 2 },
924+
left: { style: 'solid', width: 1, space: 1 },
925+
};
926+
const info: BetweenBorderInfo = {
927+
showBetweenBorder: false,
928+
suppressTopBorder: true,
929+
suppressBottomBorder: false,
930+
gapBelow: 0,
931+
};
932+
const result = computeBorderSpaceExpansion(borders, info);
933+
expect(result.top).toBe(0);
934+
expect(result.left).toBeCloseTo(1 * PX_PER_PT);
935+
});
936+
937+
it('suppresses bottom expansion when suppressBottomBorder is true', () => {
938+
const borders: ParagraphBorders = {
939+
bottom: { style: 'solid', width: 1, space: 2 },
940+
right: { style: 'solid', width: 1, space: 1 },
941+
};
942+
const info: BetweenBorderInfo = {
943+
showBetweenBorder: false,
944+
suppressTopBorder: false,
945+
suppressBottomBorder: true,
946+
gapBelow: 10,
947+
};
948+
const result = computeBorderSpaceExpansion(borders, info);
949+
expect(result.bottom).toBe(0);
950+
expect(result.right).toBeCloseTo(1 * PX_PER_PT);
951+
});
952+
953+
it('suppresses bottom expansion when showBetweenBorder is true', () => {
954+
const borders: ParagraphBorders = {
955+
bottom: { style: 'solid', width: 1, space: 2 },
956+
};
957+
const info: BetweenBorderInfo = {
958+
showBetweenBorder: true,
959+
suppressTopBorder: false,
960+
suppressBottomBorder: false,
961+
gapBelow: 8,
962+
};
963+
const result = computeBorderSpaceExpansion(borders, info);
964+
expect(result.bottom).toBe(0);
965+
});
966+
});
967+
887968
// ---------------------------------------------------------------------------
888969
// Incremental update — between-border cache invalidation
889970
// ---------------------------------------------------------------------------

packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
* @ooxml w:pPr/w:shd — paragraph shading (background fill)
1111
* @spec ECMA-376 §17.3.1.24 (pBdr), §17.3.1.31 (shd)
1212
*/
13-
import type { ParagraphAttrs, ParagraphBorder } from '@superdoc/contracts';
13+
import type { ParagraphAttrs, ParagraphBorder, ParagraphBorders } from '@superdoc/contracts';
1414
import type { BetweenBorderInfo } from './group-analysis.js';
1515

16+
const PX_PER_PT = 96 / 72;
17+
1618
// ─── Border box sizing ─────────────────────────────────────────────
1719

1820
/**
@@ -37,6 +39,35 @@ export const getParagraphBorderBox = (
3739
};
3840
};
3941

42+
// ─── Border space (padding between border and text) ─────────────────
43+
44+
/**
45+
* Computes the outward expansion for the border/shading layers based on
46+
* the `space` attribute (OOXML: distance between border and text, in points).
47+
*
48+
* Within between-border groups, suppressed sides don't expand (the gap
49+
* extension handles visual continuity instead).
50+
*
51+
* @spec ECMA-376 §17.3.1.24 — space attribute on pBdr child elements
52+
*/
53+
export const computeBorderSpaceExpansion = (
54+
borders?: ParagraphBorders,
55+
betweenInfo?: BetweenBorderInfo,
56+
): { top: number; bottom: number; left: number; right: number } => {
57+
if (!borders) return { top: 0, bottom: 0, left: 0, right: 0 };
58+
59+
const suppressTop = betweenInfo?.suppressTopBorder ?? false;
60+
const suppressBottom = betweenInfo?.suppressBottomBorder ?? false;
61+
const showBetween = betweenInfo?.showBetweenBorder ?? false;
62+
63+
return {
64+
top: !suppressTop && borders.top?.space ? borders.top.space * PX_PER_PT : 0,
65+
bottom: !suppressBottom && !showBetween && borders.bottom?.space ? borders.bottom.space * PX_PER_PT : 0,
66+
left: borders.left?.space ? borders.left.space * PX_PER_PT : 0,
67+
right: borders.right?.space ? borders.right.space * PX_PER_PT : 0,
68+
};
69+
};
70+
4071
// ─── Decoration layer factory ──────────────────────────────────────
4172

4273
/**
@@ -48,6 +79,9 @@ export const getParagraphBorderBox = (
4879
* gap, making left/right borders visually continuous across the group.
4980
* - `suppressTopBorder` hides the top border for non-first group members.
5081
* - `showBetweenBorder` replaces the bottom border with the between definition.
82+
*
83+
* The `space` attribute on each border side expands the layer outward,
84+
* creating padding between the border line and the paragraph text.
5185
*/
5286
export const createParagraphDecorationLayers = (
5387
doc: Document,
@@ -58,19 +92,22 @@ export const createParagraphDecorationLayers = (
5892
if (!attrs?.borders && !attrs?.shading) return {};
5993

6094
const borderBox = getParagraphBorderBox(fragmentWidth, attrs.indent);
95+
const space = computeBorderSpaceExpansion(attrs.borders, betweenInfo);
6196

6297
// Extend layers into the spacing gap for continuous group borders.
6398
// Both real between (showBetweenBorder) and nil/none between (suppressBottomBorder)
6499
// need gap extension to keep left/right borders continuous through the spacing gap.
65100
const gapExtension = betweenInfo?.showBetweenBorder || betweenInfo?.suppressBottomBorder ? betweenInfo!.gapBelow : 0;
66-
const bottomValue = gapExtension > 0 ? `-${gapExtension}px` : '0px';
101+
const totalBottomExpansion = gapExtension + space.bottom;
102+
const bottomValue = totalBottomExpansion > 0 ? `-${totalBottomExpansion}px` : '0px';
103+
const topValue = space.top > 0 ? `-${space.top}px` : '0px';
67104

68105
const baseStyles = {
69106
position: 'absolute',
70-
top: '0px',
107+
top: topValue,
71108
bottom: bottomValue,
72-
left: `${borderBox.leftInset}px`,
73-
width: `${borderBox.width}px`,
109+
left: `${borderBox.leftInset - space.left}px`,
110+
width: `${borderBox.width + space.left + space.right}px`,
74111
pointerEvents: 'none',
75112
boxSizing: 'border-box',
76113
} as const;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
applyParagraphShadingStyles,
2626
getParagraphBorderBox,
2727
stampBetweenBorderDataset,
28+
computeBorderSpaceExpansion,
2829
} from './border-layer.js';
2930

3031
// Shared types

0 commit comments

Comments
 (0)