Skip to content

Commit a8e71ee

Browse files
committed
feat(painter): add ECMA-376 §17.4.66 cell-border conflict resolution
Add resolveBorderConflict and isPresentBorder to collapse two cells' shared-edge borders to the single displayed border: if either side is nil/none the opposing border wins; otherwise greater weight (lines x style number) wins, with ties broken by style precedence, then color brightness, then reading order. Pure helper with unit tests; wired into the table painter in the following commit.
1 parent d1a4457 commit a8e71ee

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

packages/layout-engine/painters/dom/src/table/border-utils.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
hasExplicitCellBorders,
2323
swapTableBordersLR,
2424
swapCellBordersLR,
25+
resolveBorderConflict,
2526
} from './border-utils.js';
2627

2728
describe('applyBorder', () => {
@@ -522,3 +523,42 @@ describe('swapCellBordersLR', () => {
522523
expect(swapCellBordersLR(undefined)).toBeUndefined();
523524
});
524525
});
526+
527+
describe('resolveBorderConflict (ECMA-376 §17.4.66)', () => {
528+
const D9 = { style: 'single' as const, width: 1.333, color: '#BDD7EE' };
529+
530+
it('collapses two identical borders to one (symmetric → no doubling)', () => {
531+
// The M&A checklist case: adjacent cells both specify the same border.
532+
const winner = resolveBorderConflict(D9, { ...D9 });
533+
expect(winner).toMatchObject({ style: 'single', color: '#BDD7EE' });
534+
});
535+
536+
it('keeps the present border when the opposing side is none (asymmetric → no dropped border)', () => {
537+
// The it1007 case: header has a bottom border, the body cell below has no top.
538+
const headerBottom = { style: 'single' as const, width: 1, color: '#000000' };
539+
expect(resolveBorderConflict(undefined, headerBottom)).toEqual(headerBottom);
540+
expect(resolveBorderConflict({ style: 'none' }, headerBottom)).toEqual(headerBottom);
541+
expect(resolveBorderConflict(headerBottom, undefined)).toEqual(headerBottom);
542+
});
543+
544+
it('returns undefined when neither side has a border', () => {
545+
expect(resolveBorderConflict(undefined, undefined)).toBeUndefined();
546+
expect(resolveBorderConflict({ style: 'none' }, { style: 'none' })).toBeUndefined();
547+
expect(resolveBorderConflict({ style: 'single', width: 0, color: '#000' }, undefined)).toBeUndefined();
548+
});
549+
550+
it('the heavier-weight border wins (double over single)', () => {
551+
const single = { style: 'single' as const, width: 1, color: '#000000' };
552+
const dbl = { style: 'double' as const, width: 1, color: '#000000' };
553+
// weight: single = 1×1 = 1, double = 2×3 = 6 → double wins
554+
expect(resolveBorderConflict(single, dbl)).toEqual(dbl);
555+
expect(resolveBorderConflict(dbl, single)).toEqual(dbl);
556+
});
557+
558+
it('on equal weight + identical style, the darker color wins', () => {
559+
const dark = { style: 'single' as const, width: 1, color: '#000000' };
560+
const light = { style: 'single' as const, width: 1, color: '#FFFFFF' };
561+
// brightness(R+B+2G): dark=0 < light=1020 → dark wins
562+
expect(resolveBorderConflict(light, dark)).toEqual(dark);
563+
});
564+
});

packages/layout-engine/painters/dom/src/table/border-utils.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,89 @@ export const resolveTableBorderValue = (
183183
return borderValueToSpec(fallback);
184184
};
185185

186+
// Border "number" per ECMA-376 §17.4.66 (only the realistic styles; unknown → 1).
187+
const BORDER_STYLE_NUMBER: Partial<Record<BorderStyle, number>> = {
188+
single: 1,
189+
thick: 2,
190+
double: 3,
191+
dotted: 4,
192+
dashed: 5,
193+
dotDash: 6,
194+
dotDotDash: 7,
195+
triple: 8,
196+
wave: 18,
197+
doubleWave: 19,
198+
};
199+
// Number of drawn lines per style (single=1, double=2, triple=3, …).
200+
const BORDER_STYLE_LINES: Partial<Record<BorderStyle, number>> = {
201+
single: 1,
202+
thick: 1,
203+
double: 2,
204+
dotted: 1,
205+
dashed: 1,
206+
dotDash: 1,
207+
dotDotDash: 1,
208+
triple: 3,
209+
wave: 1,
210+
doubleWave: 2,
211+
};
212+
213+
export const isPresentBorder = (b?: BorderSpec): b is BorderSpec =>
214+
!!b && b.style !== undefined && b.style !== 'none' && (b.width === undefined || b.width > 0);
215+
216+
const borderWeight = (b: BorderSpec): number =>
217+
(BORDER_STYLE_LINES[b.style as BorderStyle] ?? 1) * (BORDER_STYLE_NUMBER[b.style as BorderStyle] ?? 1);
218+
219+
const colorBrightness = (color: string | undefined, formula: (r: number, g: number, bl: number) => number): number => {
220+
const hex = (color ?? '#000000').replace('#', '');
221+
if (hex.length < 6) return 0;
222+
const r = parseInt(hex.slice(0, 2), 16);
223+
const g = parseInt(hex.slice(2, 4), 16);
224+
const bl = parseInt(hex.slice(4, 6), 16);
225+
return formula(r, g, bl);
226+
};
227+
228+
/**
229+
* OOXML cell-border conflict resolution (ECMA-376 §17.4.66).
230+
*
231+
* With zero cell spacing, two cells sharing an edge each specify a border; the spec
232+
* collapses them to a SINGLE displayed border:
233+
* 1. If either side is nil/none/absent, the opposing (present) border is displayed.
234+
* 2. Otherwise the border with greater weight wins, where
235+
* weight = (#lines in the style) × (style number).
236+
* 3. Equal weight → the style higher on the precedence list (single first) wins.
237+
* 4. Identical style → the color with the smaller brightness (R+B+2G, then B+2G, then
238+
* G) wins; finally the first border (reading order) wins.
239+
*
240+
* @param a - One side's border (the owning cell's, e.g. the lower/right cell)
241+
* @param b - The opposing side's border (e.g. the upper/left neighbor)
242+
* @returns The single BorderSpec to display, or undefined if neither is present.
243+
*/
244+
export const resolveBorderConflict = (a?: BorderSpec, b?: BorderSpec): BorderSpec | undefined => {
245+
const pa = isPresentBorder(a);
246+
const pb = isPresentBorder(b);
247+
if (!pa && !pb) return undefined;
248+
if (!pa) return b;
249+
if (!pb) return a;
250+
const wa = borderWeight(a);
251+
const wb = borderWeight(b);
252+
if (wa !== wb) return wa > wb ? a : b;
253+
const na = BORDER_STYLE_NUMBER[a.style as BorderStyle] ?? 99;
254+
const nb = BORDER_STYLE_NUMBER[b.style as BorderStyle] ?? 99;
255+
if (na !== nb) return na < nb ? a : b;
256+
const formulas: Array<(r: number, g: number, bl: number) => number> = [
257+
(r, g, bl) => r + bl + 2 * g,
258+
(_r, g, bl) => bl + 2 * g,
259+
(_r, g) => g,
260+
];
261+
for (const f of formulas) {
262+
const ba = colorBrightness(a.color, f);
263+
const bb = colorBrightness(b.color, f);
264+
if (ba !== bb) return ba < bb ? a : b;
265+
}
266+
return a;
267+
};
268+
186269
/**
187270
* Creates a border overlay element for a table fragment.
188271
*

0 commit comments

Comments
 (0)