Skip to content

Commit 4110a37

Browse files
committed
feat: support paragraph between borders (w:pBdr/w:between)
Extract paragraph border rendering into feature module at features/paragraph-borders/ with improved between-border support. - BetweenBorderInfo type replaces boolean flag, carrying gap extension and top suppression data per fragment - Border layers extend into spacing gaps for continuous group borders - Top borders suppressed for non-first group members - nil/none between borders no longer form groups - Feature registry maps OOXML elements to rendering modules
1 parent 5ba249c commit 4110a37

8 files changed

Lines changed: 502 additions & 262 deletions

File tree

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
getFragmentParagraphBorders,
55
computeBetweenBorderFlags,
66
type BlockLookup,
7-
} from './renderer.js';
7+
type BetweenBorderInfo,
8+
} from './features/paragraph-borders/index.js';
9+
10+
/** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */
11+
const betweenOn: BetweenBorderInfo = { showBetweenBorder: true, suppressTopBorder: false, gapBelow: 0 };
12+
const betweenOff: BetweenBorderInfo = { showBetweenBorder: false, suppressTopBorder: false, gapBelow: 0 };
813
import { createDomPainter } from './index.js';
914
import type {
1015
ParagraphBorders,
@@ -123,7 +128,7 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
123128
top: { style: 'solid', width: 1, color: '#000' },
124129
between: { style: 'solid', width: 2, color: '#FF0000' },
125130
};
126-
applyParagraphBorderStyles(e, borders, false);
131+
applyParagraphBorderStyles(e, borders, betweenOff);
127132
expect(e.style.getPropertyValue('border-top-style')).toBe('solid');
128133
expect(e.style.getPropertyValue('border-bottom-style')).toBe('');
129134
});
@@ -136,7 +141,7 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
136141

137142
it('applies between border as bottom border when showBetweenBorder is true', () => {
138143
const e = el();
139-
applyParagraphBorderStyles(e, { between: { style: 'dashed', width: 3, color: '#0F0' } }, true);
144+
applyParagraphBorderStyles(e, { between: { style: 'dashed', width: 3, color: '#0F0' } }, betweenOn);
140145
expect(e.style.getPropertyValue('border-bottom-style')).toBe('dashed');
141146
expect(e.style.getPropertyValue('border-bottom-width')).toBe('3px');
142147
expect(e.style.getPropertyValue('border-bottom-color')).toBe('#0F0');
@@ -148,7 +153,7 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
148153
applyParagraphBorderStyles(
149154
e,
150155
{ bottom: { style: 'solid', width: 1, color: '#000' }, between: { style: 'double', width: 4, color: '#F00' } },
151-
true,
156+
betweenOn,
152157
);
153158
expect(e.style.getPropertyValue('border-bottom-style')).toBe('double');
154159
expect(e.style.getPropertyValue('border-bottom-width')).toBe('4px');
@@ -160,7 +165,7 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
160165
applyParagraphBorderStyles(
161166
e,
162167
{ bottom: { style: 'solid', width: 1, color: '#000' }, between: { style: 'double', width: 4, color: '#F00' } },
163-
false,
168+
betweenOff,
164169
);
165170
expect(e.style.getPropertyValue('border-bottom-style')).toBe('solid');
166171
expect(e.style.getPropertyValue('border-bottom-width')).toBe('1px');
@@ -176,7 +181,7 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
176181
left: { style: 'solid', width: 1, color: '#000' },
177182
between: { style: 'dashed', width: 2, color: '#F00' },
178183
};
179-
applyParagraphBorderStyles(e, borders, true);
184+
applyParagraphBorderStyles(e, borders, betweenOn);
180185
expect(e.style.getPropertyValue('border-top-style')).toBe('solid');
181186
expect(e.style.getPropertyValue('border-right-style')).toBe('solid');
182187
expect(e.style.getPropertyValue('border-left-style')).toBe('solid');
@@ -187,59 +192,59 @@ describe('applyParagraphBorderStyles β€” between borders', () => {
187192
// --- partial / degenerate border specs ---
188193
it('handles between border with none style', () => {
189194
const e = el();
190-
applyParagraphBorderStyles(e, { between: { style: 'none', width: 0, color: '#000' } }, true);
195+
applyParagraphBorderStyles(e, { between: { style: 'none', width: 0, color: '#000' } }, betweenOn);
191196
expect(e.style.getPropertyValue('border-bottom-style')).toBe('none');
192197
expect(e.style.getPropertyValue('border-bottom-width')).toBe('0px');
193198
});
194199

195200
it('defaults width to 1px when between border has no width', () => {
196201
const e = el();
197-
applyParagraphBorderStyles(e, { between: { style: 'solid', color: '#F00' } }, true);
202+
applyParagraphBorderStyles(e, { between: { style: 'solid', color: '#F00' } }, betweenOn);
198203
expect(e.style.getPropertyValue('border-bottom-width')).toBe('1px');
199204
});
200205

201206
it('defaults color to #000 when between border has no color', () => {
202207
const e = el();
203-
applyParagraphBorderStyles(e, { between: { style: 'solid', width: 2 } }, true);
208+
applyParagraphBorderStyles(e, { between: { style: 'solid', width: 2 } }, betweenOn);
204209
expect(e.style.getPropertyValue('border-bottom-color')).toBe('#000');
205210
});
206211

207212
it('defaults style to solid when between border has no style', () => {
208213
const e = el();
209-
applyParagraphBorderStyles(e, { between: { width: 2, color: '#F00' } }, true);
214+
applyParagraphBorderStyles(e, { between: { width: 2, color: '#F00' } }, betweenOn);
210215
expect(e.style.getPropertyValue('border-bottom-style')).toBe('solid');
211216
});
212217

213218
it('handles between border with only width', () => {
214219
const e = el();
215-
applyParagraphBorderStyles(e, { between: { width: 5 } }, true);
220+
applyParagraphBorderStyles(e, { between: { width: 5 } }, betweenOn);
216221
expect(e.style.getPropertyValue('border-bottom-style')).toBe('solid');
217222
expect(e.style.getPropertyValue('border-bottom-width')).toBe('5px');
218223
expect(e.style.getPropertyValue('border-bottom-color')).toBe('#000');
219224
});
220225

221226
it('clamps negative width to 0px', () => {
222227
const e = el();
223-
applyParagraphBorderStyles(e, { between: { style: 'solid', width: -3 } }, true);
228+
applyParagraphBorderStyles(e, { between: { style: 'solid', width: -3 } }, betweenOn);
224229
expect(e.style.getPropertyValue('border-bottom-width')).toBe('0px');
225230
});
226231

227232
it('handles width=0 (renders as zero-width border)', () => {
228233
const e = el();
229-
applyParagraphBorderStyles(e, { between: { style: 'solid', width: 0 } }, true);
234+
applyParagraphBorderStyles(e, { between: { style: 'solid', width: 0 } }, betweenOn);
230235
expect(e.style.getPropertyValue('border-bottom-width')).toBe('0px');
231236
});
232237

233238
it('no-ops when showBetweenBorder=true but borders.between is undefined', () => {
234239
const e = el();
235-
applyParagraphBorderStyles(e, { top: { style: 'solid', width: 1 } }, true);
240+
applyParagraphBorderStyles(e, { top: { style: 'solid', width: 1 } }, betweenOn);
236241
// Should not crash, and no bottom border should appear
237242
expect(e.style.getPropertyValue('border-bottom-style')).toBe('');
238243
});
239244

240245
it('no-ops when borders is undefined', () => {
241246
const e = el();
242-
applyParagraphBorderStyles(e, undefined, true);
247+
applyParagraphBorderStyles(e, undefined, betweenOn);
243248
expect(e.style.getPropertyValue('border-bottom-style')).toBe('');
244249
});
245250
});
@@ -308,7 +313,11 @@ describe('computeBetweenBorderFlags', () => {
308313

309314
const flags = computeBetweenBorderFlags(fragments, lookup);
310315
expect(flags.has(0)).toBe(true);
311-
expect(flags.size).toBe(1);
316+
expect(flags.get(0)?.showBetweenBorder).toBe(true);
317+
// Fragment 1 also gets an entry (suppressTopBorder)
318+
expect(flags.has(1)).toBe(true);
319+
expect(flags.get(1)?.suppressTopBorder).toBe(true);
320+
expect(flags.size).toBe(2);
312321
});
313322

314323
it('does not flag when between border is not defined', () => {
@@ -436,8 +445,14 @@ describe('computeBetweenBorderFlags', () => {
436445

437446
const flags = computeBetweenBorderFlags(fragments, lookup);
438447
expect(flags.has(0)).toBe(true);
448+
expect(flags.get(0)?.showBetweenBorder).toBe(true);
439449
expect(flags.has(1)).toBe(true);
440-
expect(flags.size).toBe(2);
450+
expect(flags.get(1)?.showBetweenBorder).toBe(true);
451+
expect(flags.get(1)?.suppressTopBorder).toBe(true);
452+
expect(flags.has(2)).toBe(true);
453+
expect(flags.get(2)?.suppressTopBorder).toBe(true);
454+
expect(flags.get(2)?.showBetweenBorder).toBe(false);
455+
expect(flags.size).toBe(3);
441456
});
442457

443458
it('breaks chain when middle paragraph has different borders', () => {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Rendering Feature Registry
3+
*
4+
* Maps OOXML elements to their rendering feature modules.
5+
* This is the primary lookup table for agents and developers.
6+
*
7+
* To find where an OOXML element renders: search this file.
8+
* To add a new rendering feature: add an entry here first.
9+
*
10+
* Each entry specifies:
11+
* - feature: human-readable feature name (matches folder name)
12+
* - module: import path relative to this file
13+
* - handles: list of OOXML element paths this feature renders
14+
* - spec: ECMA-376 section reference
15+
*/
16+
export const RENDERING_FEATURES = {
17+
// ─── Paragraph Borders ───────────────────────────────────────────
18+
// @spec ECMA-376 Β§17.3.1.24 (pBdr)
19+
'w:pBdr': {
20+
feature: 'paragraph-borders',
21+
module: './paragraph-borders',
22+
handles: ['w:pBdr/w:top', 'w:pBdr/w:bottom', 'w:pBdr/w:left', 'w:pBdr/w:right', 'w:pBdr/w:between', 'w:pBdr/w:bar'],
23+
spec: 'Β§17.3.1.24',
24+
},
25+
26+
// ─── Paragraph Shading ───────────────────────────────────────────
27+
// @spec ECMA-376 Β§17.3.1.31 (shd)
28+
'w:shd': {
29+
feature: 'paragraph-borders', // shading shares the border layer module
30+
module: './paragraph-borders',
31+
handles: ['w:shd/@w:fill', 'w:shd/@w:val', 'w:shd/@w:color'],
32+
spec: 'Β§17.3.1.31',
33+
},
34+
} as const;
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Paragraph border DOM layer creation and CSS styling.
3+
*
4+
* Creates absolutely-positioned overlay elements for paragraph borders
5+
* and shading, with indent-aware sizing and between-border group support.
6+
*
7+
* @ooxml w:pPr/w:pBdr β€” paragraph border properties
8+
* @ooxml w:pPr/w:pBdr/w:top, w:bottom, w:left, w:right β€” side borders
9+
* @ooxml w:pPr/w:pBdr/w:between β€” between border (rendered as bottom within groups)
10+
* @ooxml w:pPr/w:shd β€” paragraph shading (background fill)
11+
* @spec ECMA-376 Β§17.3.1.24 (pBdr), Β§17.3.1.31 (shd)
12+
*/
13+
import type { ParagraphAttrs, ParagraphBorder } from '@superdoc/contracts';
14+
import type { BetweenBorderInfo } from './group-analysis.js';
15+
16+
// ─── Border box sizing ─────────────────────────────────────────────
17+
18+
/**
19+
* Computes the indent-aware bounding box for paragraph border/shading layers.
20+
* Borders hug the paragraph content, inset by left/right/firstLine/hanging indents.
21+
*/
22+
export const getParagraphBorderBox = (
23+
fragmentWidth: number,
24+
indent?: ParagraphAttrs['indent'],
25+
): { leftInset: number; width: number } => {
26+
const indentLeft = Number.isFinite(indent?.left) ? indent!.left! : 0;
27+
const indentRight = Number.isFinite(indent?.right) ? indent!.right! : 0;
28+
const firstLine = Number.isFinite(indent?.firstLine) ? indent!.firstLine! : 0;
29+
const hanging = Number.isFinite(indent?.hanging) ? indent!.hanging! : 0;
30+
const firstLineOffset = firstLine - hanging;
31+
const minLeftInset = Math.min(indentLeft, indentLeft + firstLineOffset);
32+
const leftInset = Math.max(0, minLeftInset);
33+
const rightInset = Math.max(0, indentRight);
34+
return {
35+
leftInset,
36+
width: Math.max(0, fragmentWidth - leftInset - rightInset),
37+
};
38+
};
39+
40+
// ─── Decoration layer factory ──────────────────────────────────────
41+
42+
/**
43+
* Builds overlay elements for paragraph shading and borders.
44+
* Returns layers in the order they should be appended (shading below borders).
45+
*
46+
* When `betweenInfo` indicates this fragment is in a border group:
47+
* - The border layer extends downward by `gapBelow` px into the paragraph-spacing
48+
* gap, making left/right borders visually continuous across the group.
49+
* - `suppressTopBorder` hides the top border for non-first group members.
50+
* - `showBetweenBorder` replaces the bottom border with the between definition.
51+
*/
52+
export const createParagraphDecorationLayers = (
53+
doc: Document,
54+
fragmentWidth: number,
55+
attrs?: ParagraphAttrs,
56+
betweenInfo?: BetweenBorderInfo,
57+
): { shadingLayer?: HTMLElement; borderLayer?: HTMLElement } => {
58+
if (!attrs?.borders && !attrs?.shading) return {};
59+
60+
const borderBox = getParagraphBorderBox(fragmentWidth, attrs.indent);
61+
62+
// Extend layers into the spacing gap for continuous group borders
63+
const gapExtension = betweenInfo?.showBetweenBorder ? betweenInfo.gapBelow : 0;
64+
const bottomValue = gapExtension > 0 ? `-${gapExtension}px` : '0px';
65+
66+
const baseStyles = {
67+
position: 'absolute',
68+
top: '0px',
69+
bottom: bottomValue,
70+
left: `${borderBox.leftInset}px`,
71+
width: `${borderBox.width}px`,
72+
pointerEvents: 'none',
73+
boxSizing: 'border-box',
74+
} as const;
75+
76+
let shadingLayer: HTMLElement | undefined;
77+
if (attrs.shading) {
78+
shadingLayer = doc.createElement('div');
79+
shadingLayer.classList.add('superdoc-paragraph-shading');
80+
Object.assign(shadingLayer.style, baseStyles);
81+
applyParagraphShadingStyles(shadingLayer, attrs.shading);
82+
}
83+
84+
let borderLayer: HTMLElement | undefined;
85+
if (attrs.borders) {
86+
borderLayer = doc.createElement('div');
87+
borderLayer.classList.add('superdoc-paragraph-border');
88+
Object.assign(borderLayer.style, baseStyles);
89+
borderLayer.style.zIndex = '1';
90+
applyParagraphBorderStyles(borderLayer, attrs.borders, betweenInfo);
91+
}
92+
93+
return { shadingLayer, borderLayer };
94+
};
95+
96+
// ─── Border CSS application ────────────────────────────────────────
97+
98+
type CssBorderSide = 'top' | 'right' | 'bottom' | 'left';
99+
const BORDER_SIDES: CssBorderSide[] = ['top', 'right', 'bottom', 'left'];
100+
101+
/**
102+
* Applies paragraph border styles to an HTML element.
103+
*
104+
* Handles between-border groups:
105+
* - `suppressTopBorder`: skips top border for non-first group members
106+
* - `showBetweenBorder`: replaces bottom with the between border definition
107+
*/
108+
export const applyParagraphBorderStyles = (
109+
element: HTMLElement,
110+
borders?: ParagraphAttrs['borders'],
111+
betweenInfo?: BetweenBorderInfo,
112+
): void => {
113+
if (!borders) return;
114+
const showBetweenBorder = betweenInfo?.showBetweenBorder ?? false;
115+
const suppressTopBorder = betweenInfo?.suppressTopBorder ?? false;
116+
117+
element.style.boxSizing = 'border-box';
118+
BORDER_SIDES.forEach((side) => {
119+
if (side === 'top' && suppressTopBorder) return;
120+
const border = borders[side];
121+
if (!border) return;
122+
setBorderSideStyle(element, side, border);
123+
});
124+
125+
// Between border renders as a bottom border, overwriting any normal bottom border
126+
// when the fragment is within a border group (consecutive paragraphs with matching borders)
127+
if (showBetweenBorder && borders.between) {
128+
setBorderSideStyle(element, 'bottom', borders.between);
129+
}
130+
};
131+
132+
const setBorderSideStyle = (element: HTMLElement, side: CssBorderSide, border: ParagraphBorder): void => {
133+
const resolvedStyle =
134+
border.style && border.style !== 'none' ? border.style : border.style === 'none' ? 'none' : 'solid';
135+
if (resolvedStyle === 'none') {
136+
element.style.setProperty(`border-${side}-style`, 'none');
137+
element.style.setProperty(`border-${side}-width`, '0px');
138+
if (border.color) {
139+
element.style.setProperty(`border-${side}-color`, border.color);
140+
}
141+
return;
142+
}
143+
144+
const width = border.width != null ? Math.max(0, border.width) : undefined;
145+
element.style.setProperty(`border-${side}-style`, resolvedStyle);
146+
element.style.setProperty(`border-${side}-width`, `${width ?? 1}px`);
147+
element.style.setProperty(`border-${side}-color`, border.color ?? '#000');
148+
};
149+
150+
// ─── Shading CSS application ───────────────────────────────────────
151+
152+
/**
153+
* Applies paragraph shading (background color) to an HTML element.
154+
*/
155+
export const applyParagraphShadingStyles = (element: HTMLElement, shading?: ParagraphAttrs['shading']): void => {
156+
if (!shading?.fill) return;
157+
element.style.backgroundColor = shading.fill;
158+
};

0 commit comments

Comments
Β (0)