Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,7 @@ export type ParagraphBorders = {
right?: ParagraphBorder;
bottom?: ParagraphBorder;
left?: ParagraphBorder;
bar?: ParagraphBorder;
between?: ParagraphBorder;
};

Expand Down
5 changes: 3 additions & 2 deletions packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ const paragraphBorderEqual = (a?: ParagraphBorder, b?: ParagraphBorder): boolean
};

/**
* Compares paragraph borders (all four sides) for equality.
* Compares paragraph borders for equality.
* Borders affect the visual box around the paragraph.
*/
const paragraphBordersEqual = (a?: ParagraphBorders, b?: ParagraphBorders): boolean => {
Expand All @@ -246,7 +246,8 @@ const paragraphBordersEqual = (a?: ParagraphBorders, b?: ParagraphBorders): bool
paragraphBorderEqual(a.top, b.top) &&
paragraphBorderEqual(a.right, b.right) &&
paragraphBorderEqual(a.bottom, b.bottom) &&
paragraphBorderEqual(a.left, b.left)
paragraphBorderEqual(a.left, b.left) &&
paragraphBorderEqual(a.bar, b.bar)
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const hashParagraphBorder = (border: ParagraphBorder): string => {

/**
* Creates a deterministic hash string for paragraph borders.
* Hashes all four sides (top, right, bottom, left) in a consistent order.
* Hashes paragraph border sides (top, right, bottom, left, bar, between) in a consistent order.
*
* @param borders - The paragraph borders to hash
* @returns A deterministic hash string
Expand All @@ -38,6 +38,7 @@ export const hashParagraphBorders = (borders: ParagraphBorders): string => {
if (borders.right) parts.push(`r:[${hashParagraphBorder(borders.right)}]`);
if (borders.bottom) parts.push(`b:[${hashParagraphBorder(borders.bottom)}]`);
if (borders.left) parts.push(`l:[${hashParagraphBorder(borders.left)}]`);
if (borders.bar) parts.push(`bar:[${hashParagraphBorder(borders.bar)}]`);
if (borders.between) parts.push(`bw:[${hashParagraphBorder(borders.between)}]`);
return parts.join(';');
};
Expand Down
7 changes: 7 additions & 0 deletions packages/layout-engine/layout-bridge/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@ describe('computeDirtyRegions', () => {
expect(result.firstDirtyIndex).toBe(0);
});

it('detects bar border change', () => {
const prev = [paragraphWithAttrs('p1', 'Hello', { borders: { bar: { style: 'solid', width: 1 } } })];
const next = [paragraphWithAttrs('p1', 'Hello', { borders: { bar: { style: 'double', width: 2 } } })];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(0);
});

it('treats identical borders as stable', () => {
const prev = [
paragraphWithAttrs('p1', 'Hello', { borders: { top: { style: 'solid', width: 1, color: '#000' } } }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,70 +400,78 @@ describe('hashCellBorders', () => {
});

describe('hashParagraphBorders', () => {
it('includes between border in hash with bw: prefix', () => {
it('includes bar and between borders in hash', () => {
const borders: ParagraphBorders = {
top: { style: 'solid', width: 1, color: '#000' },
bar: { style: 'double', width: 3, color: '#00FF00' },
between: { style: 'solid', width: 2, color: '#FF0000' },
};
const hash = hashParagraphBorders(borders);
expect(hash).toContain('t:[');
expect(hash).toContain('bar:[');
expect(hash).toContain('bw:[');
expect(hash).toContain('w:2');
expect(hash).toContain('w:3');
});

it('produces different hashes with and without between', () => {
it('produces different hashes with and without bar', () => {
const with_: ParagraphBorders = {
top: { style: 'solid', width: 1 },
between: { style: 'solid', width: 1 },
bar: { style: 'solid', width: 1 },
};
const without_: ParagraphBorders = {
top: { style: 'solid', width: 1 },
};
expect(hashParagraphBorders(with_)).not.toBe(hashParagraphBorders(without_));
});

it('does not include bw: when between is undefined', () => {
it('does not include bar: or bw: when bar and between are undefined', () => {
const borders: ParagraphBorders = {
top: { style: 'solid', width: 1 },
bottom: { style: 'solid', width: 1 },
};
expect(hashParagraphBorders(borders)).not.toContain('bw:');
const hash = hashParagraphBorders(borders);
expect(hash).not.toContain('bar:');
expect(hash).not.toContain('bw:');
});

it('places bw: after l: in hash output', () => {
it('places bar: after l: and before bw: in hash output', () => {
const borders: ParagraphBorders = {
left: { style: 'solid', width: 1 },
bar: { style: 'solid', width: 2 },
between: { style: 'solid', width: 1 },
};
const hash = hashParagraphBorders(borders);
expect(hash.indexOf('l:[')).toBeLessThan(hash.indexOf('bw:['));
expect(hash.indexOf('l:[')).toBeLessThan(hash.indexOf('bar:['));
expect(hash.indexOf('bar:[')).toBeLessThan(hash.indexOf('bw:['));
});
});

describe('hashParagraphAttrs', () => {
it('includes between border in attrs hash via borders', () => {
it('includes bar border in attrs hash via borders', () => {
const attrs: ParagraphAttrs = {
borders: {
top: { style: 'solid', width: 1 },
bar: { style: 'double', width: 2, color: '#0F0' },
between: { style: 'solid', width: 2, color: '#F00' },
},
};
const hash = hashParagraphAttrs(attrs);
expect(hash).toContain('br:');
expect(hash).toContain('bar:[');
expect(hash).toContain('bw:[');
});

it('produces different hashes when between border changes', () => {
it('produces different hashes when bar border changes', () => {
const attrs1: ParagraphAttrs = {
borders: {
top: { style: 'solid', width: 1 },
between: { style: 'solid', width: 1 },
bar: { style: 'solid', width: 1 },
},
};
const attrs2: ParagraphAttrs = {
borders: {
top: { style: 'solid', width: 1 },
between: { style: 'dashed', width: 2 },
bar: { style: 'dashed', width: 2 },
},
};
expect(hashParagraphAttrs(attrs1)).not.toBe(hashParagraphAttrs(attrs2));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const hashBorders = (borders?: ParagraphBorders): string | undefined => {
if (!borders) return undefined;
const side = (b?: { style?: string; width?: number; color?: string; space?: number }) =>
b ? `${b.style ?? ''},${b.width ?? 0},${b.color ?? ''},${b.space ?? 0}` : '';
return `${side(borders.top)}|${side(borders.right)}|${side(borders.bottom)}|${side(borders.left)}|${side(borders.between)}`;
return `${side(borders.top)}|${side(borders.right)}|${side(borders.bottom)}|${side(borders.left)}|${side(borders.bar)}|${side(borders.between)}`;
};

/**
Expand Down
52 changes: 52 additions & 0 deletions packages/layout-engine/painters/dom/src/between-borders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,39 @@ describe('applyParagraphBorderStyles — between borders', () => {
expect(e.style.getPropertyValue('border-bottom-width')).toBe('2px');
});

it('renders bar as a dedicated child element without mutating the parent left border', () => {
const e = el();
applyParagraphBorderStyles(e, { bar: { style: 'double', width: 2, color: '#F00' } }, betweenOff);
const bar = e.querySelector('.superdoc-paragraph-bar') as HTMLElement | null;

expect(e.style.getPropertyValue('border-left-style')).toBe('');
expect(bar).not.toBeNull();
expect(bar?.style.left).toBe('0px');
expect(bar?.style.pointerEvents).toBe('none');
expect(bar?.style.borderLeftStyle).toBe('double');
expect(bar?.style.borderLeftWidth).toBe('2px');
expect(bar?.style.borderLeftColor).toBe('#F00');
});

it('keeps left border rendering and offsets bar outside it with bar spacing', () => {
const e = el();
applyParagraphBorderStyles(
e,
{
left: { style: 'solid', width: 4, color: '#000' },
bar: { style: 'solid', width: 2, color: '#F00', space: 3 },
},
betweenOff,
);
const bar = e.querySelector('.superdoc-paragraph-bar') as HTMLElement | null;

expect(e.style.getPropertyValue('border-left-style')).toBe('solid');
expect(e.style.getPropertyValue('border-left-width')).toBe('4px');
expect(bar).not.toBeNull();
expect(bar?.style.left).toBe('-8px');
expect(bar?.style.borderLeftWidth).toBe('2px');
});

// --- partial / degenerate border specs ---
it('handles between border with none style', () => {
const e = el();
Expand Down Expand Up @@ -689,6 +722,25 @@ describe('computeBetweenBorderFlags', () => {
expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heads up — this test expects bar-only differences to break grouping, but Word and Google Docs both keep them grouped. if the grouping change above goes in, this test would need to flip.


it('does not flag when only bar differs (different group)', () => {
const borders1: ParagraphBorders = {
top: { style: 'solid', width: 1, color: '#000' },
bar: { style: 'solid', width: 2, color: '#0F0' },
between: { style: 'solid', width: 1, color: '#000' },
};
const borders2: ParagraphBorders = {
top: { style: 'solid', width: 1, color: '#000' },
bar: { style: 'double', width: 3, color: '#F00' },
between: { style: 'solid', width: 1, color: '#000' },
};
const b1 = makeParagraphBlock('b1', borders1);
const b2 = makeParagraphBlock('b2', borders2);
const lookup = buildLookup([{ block: b1 }, { block: b2 }]);
const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')];

expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0);
});

// --- edge: last fragment on page ---
it('last fragment on page is never flagged (no next to pair with)', () => {
const b1 = makeParagraphBlock('b1', MATCHING_BORDERS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @ooxml w:pPr/w:pBdr — paragraph border properties
* @ooxml w:pPr/w:pBdr/w:top, w:bottom, w:left, w:right — side borders
* @ooxml w:pPr/w:pBdr/w:bar — bar border (rendered as a separate left-side rule)
* @ooxml w:pPr/w:pBdr/w:between — between border (rendered as bottom within groups)
* @ooxml w:pPr/w:shd — paragraph shading (background fill)
* @spec ECMA-376 §17.3.1.24 (pBdr), §17.3.1.31 (shd)
Expand Down Expand Up @@ -182,6 +183,93 @@ const computeRenderedBorderWidths = (

type CssBorderSide = 'top' | 'right' | 'bottom' | 'left';
const BORDER_SIDES: CssBorderSide[] = ['top', 'right', 'bottom', 'left'];
const PARAGRAPH_BAR_CLASS = 'superdoc-paragraph-bar';

type ResolvedParagraphBorder = {
style: 'none' | 'solid' | 'dashed' | 'dotted' | 'double';
width: number;
color: string;
};

const resolveParagraphBorder = (border: ParagraphBorder): ResolvedParagraphBorder => {
const style = border.style && border.style !== 'none' ? border.style : border.style === 'none' ? 'none' : 'solid';

if (style === 'none') {
return {
style,
width: 0,
color: border.color ?? '#000',
};
}

return {
style,
width: border.width != null ? Math.max(0, border.width) : 1,
color: border.color ?? '#000',
};
};

const getRenderedParagraphBorderWidth = (border?: ParagraphBorder): number => {
if (!border) return 0;
const resolved = resolveParagraphBorder(border);
if (resolved.style === 'none') return 0;
return resolved.width;
};

const getParagraphBarElement = (element: HTMLElement): HTMLElement | undefined => {
return Array.from(element.children).find(
(child): child is HTMLElement => child instanceof HTMLElement && child.classList.contains(PARAGRAPH_BAR_CLASS),
);
Comment on lines +219 to +222
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

element.querySelector('.superdoc-paragraph-bar') does the same thing in one line — worth switching?

};

const syncParagraphBarElement = (
element: HTMLElement,
barBorder?: ParagraphBorder,
leftBorder?: ParagraphBorder,
): void => {
const existingBarElement = getParagraphBarElement(element);
if (!barBorder) {
existingBarElement?.remove();
return;
}

const resolvedBar = resolveParagraphBorder(barBorder);
if (resolvedBar.style === 'none' || resolvedBar.width <= 0) {
existingBarElement?.remove();
return;
}

const computedPosition = element.ownerDocument.defaultView?.getComputedStyle(element).position;
if (!element.style.position && (!computedPosition || computedPosition === 'static')) {
element.style.position = 'relative';
}

const barElement = existingBarElement ?? element.ownerDocument.createElement('div');
if (!existingBarElement) {
barElement.classList.add(PARAGRAPH_BAR_CLASS);
element.appendChild(barElement);
}

const barSpace = Math.max(0, barBorder.space ?? 0) * PX_PER_PT;
const renderedLeftBorderWidth = getRenderedParagraphBorderWidth(leftBorder);

barElement.style.position = 'absolute';
barElement.style.pointerEvents = 'none';
barElement.style.boxSizing = 'border-box';
barElement.style.top = '0px';
barElement.style.bottom = '0px';
barElement.style.left = `-${renderedLeftBorderWidth + barSpace}px`;
barElement.style.width = '0px';
barElement.style.borderLeftStyle = resolvedBar.style;
barElement.style.borderLeftWidth = `${resolvedBar.width}px`;
barElement.style.borderLeftColor = resolvedBar.color;
Comment on lines +263 to +265
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these three lines do the same thing as setBorderSideStyle(barElement, 'left', barBorder) — worth reusing it?

Suggested change
barElement.style.borderLeftStyle = resolvedBar.style;
barElement.style.borderLeftWidth = `${resolvedBar.width}px`;
barElement.style.borderLeftColor = resolvedBar.color;
setBorderSideStyle(barElement, 'left', barBorder);

};

const clearBorderSideStyle = (element: HTMLElement, side: CssBorderSide): void => {
element.style.removeProperty(`border-${side}-style`);
element.style.removeProperty(`border-${side}-width`);
element.style.removeProperty(`border-${side}-color`);
};

/**
* Applies paragraph border styles to an HTML element.
Expand All @@ -195,7 +283,14 @@ export const applyParagraphBorderStyles = (
borders?: ParagraphAttrs['borders'],
betweenInfo?: BetweenBorderInfo,
): void => {
if (!borders) return;
BORDER_SIDES.forEach((side) => {
clearBorderSideStyle(element, side);
});

if (!borders) {
syncParagraphBarElement(element);
return;
}
const showBetweenBorder = betweenInfo?.showBetweenBorder ?? false;
const suppressTopBorder = betweenInfo?.suppressTopBorder ?? false;
const suppressBottomBorder = betweenInfo?.suppressBottomBorder ?? false;
Expand All @@ -214,12 +309,13 @@ export const applyParagraphBorderStyles = (
if (showBetweenBorder && borders.between) {
setBorderSideStyle(element, 'bottom', borders.between);
}

syncParagraphBarElement(element, borders.bar, borders.left);
};

const setBorderSideStyle = (element: HTMLElement, side: CssBorderSide, border: ParagraphBorder): void => {
const resolvedStyle =
border.style && border.style !== 'none' ? border.style : border.style === 'none' ? 'none' : 'solid';
if (resolvedStyle === 'none') {
const resolved = resolveParagraphBorder(border);
if (resolved.style === 'none') {
element.style.setProperty(`border-${side}-style`, 'none');
element.style.setProperty(`border-${side}-width`, '0px');
if (border.color) {
Expand All @@ -228,10 +324,9 @@ const setBorderSideStyle = (element: HTMLElement, side: CssBorderSide, border: P
return;
}

const width = border.width != null ? Math.max(0, border.width) : undefined;
element.style.setProperty(`border-${side}-style`, resolvedStyle);
element.style.setProperty(`border-${side}-width`, `${width ?? 1}px`);
element.style.setProperty(`border-${side}-color`, border.color ?? '#000');
element.style.setProperty(`border-${side}-style`, resolved.style);
element.style.setProperty(`border-${side}-width`, `${resolved.width}px`);
element.style.setProperty(`border-${side}-color`, resolved.color);
};

// ─── Dataset stamping ─────────────────────────────────────────────
Expand Down
Loading
Loading