diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 1464a2d1a5..bf3f3e9919 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1160,6 +1160,7 @@ export type ParagraphBorders = { right?: ParagraphBorder; bottom?: ParagraphBorder; left?: ParagraphBorder; + bar?: ParagraphBorder; between?: ParagraphBorder; }; diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 6eb70e6818..2424953540 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -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 => { @@ -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) ); }; diff --git a/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts b/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts index b42b84dc3a..4abc9da239 100644 --- a/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts @@ -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 @@ -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(';'); }; diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 7c762a635f..a196676a0b 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -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' } } }), diff --git a/packages/layout-engine/layout-bridge/test/paragraph-hash-utils.test.ts b/packages/layout-engine/layout-bridge/test/paragraph-hash-utils.test.ts index 172c57086f..0af255e349 100644 --- a/packages/layout-engine/layout-bridge/test/paragraph-hash-utils.test.ts +++ b/packages/layout-engine/layout-bridge/test/paragraph-hash-utils.test.ts @@ -400,21 +400,23 @@ 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 }, @@ -422,48 +424,54 @@ describe('hashParagraphBorders', () => { 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)); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 598f1503c7..2cc05e1471 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -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)}`; }; /** diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index f9ae6037fa..09612d60ef 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -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(); @@ -689,6 +722,25 @@ describe('computeBetweenBorderFlags', () => { expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); }); + 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); diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts index 67549b624a..c8e9b02be1 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts @@ -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) @@ -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), + ); +}; + +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; +}; + +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. @@ -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; @@ -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) { @@ -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 ───────────────────────────────────────────── diff --git a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.test.ts b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.test.ts index 4ef72e7592..02dc7819a1 100644 --- a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.test.ts @@ -196,34 +196,49 @@ describe('paragraph-hash-utils', () => { }); describe('hashParagraphBorders', () => { - it('includes between border in hash', () => { + 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('bar:['); expect(hash).toContain('bw:['); - expect(hash).toContain('s:solid'); - expect(hash).toContain('w:2'); - expect(hash).toContain('c:#FF0000'); + expect(hash).toContain('s:double'); + expect(hash).toContain('w:3'); + expect(hash).toContain('c:#00FF00'); }); - it('produces different hashes for borders with and without between', () => { - const withBetween: ParagraphBorders = { + it('produces different hashes when only bar changes', () => { + const withBar: ParagraphBorders = { top: { style: 'solid', width: 1 }, - between: { style: 'solid', width: 1 }, + bar: { style: 'solid', width: 1 }, }; - const withoutBetween: ParagraphBorders = { + const withoutBar: ParagraphBorders = { top: { style: 'solid', width: 1 }, }; - expect(hashParagraphBorders(withBetween)).not.toBe(hashParagraphBorders(withoutBetween)); + expect(hashParagraphBorders(withBar)).not.toBe(hashParagraphBorders(withoutBar)); + }); + + it('places bar before between in the 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('bar:[')); + expect(hash.indexOf('bar:[')).toBeLessThan(hash.indexOf('bw:[')); }); - it('does not include between segment when not defined', () => { + it('does not include bar or between segments when not defined', () => { 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:'); }); }); diff --git a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts index 55870ed7e9..6e115a29fe 100644 --- a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts @@ -29,6 +29,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(';'); }; diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.test.ts b/packages/layout-engine/pm-adapter/src/attributes/borders.test.ts index b6d1ddc3ef..6868167b20 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/borders.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/borders.test.ts @@ -497,18 +497,25 @@ describe('extractCellPadding', () => { describe('normalizeParagraphBorders', () => { describe('valid paragraph borders', () => { - it('should normalize all four border sides', () => { + it('should normalize paragraph border sides including bar', () => { const input = { top: { val: 'single', size: 1, color: 'FF0000' }, right: { val: 'double', size: 2, color: '00FF00' }, bottom: { val: 'dashed', size: 3, color: '0000FF' }, left: { val: 'dotted', size: 4, color: 'FFFF00' }, + bar: { val: 'single', size: 5, color: 'FF00FF', space: 2 }, }; const result = normalizeParagraphBorders(input); expect(result?.top).toBeDefined(); expect(result?.right).toBeDefined(); expect(result?.bottom).toBeDefined(); expect(result?.left).toBeDefined(); + expect(result?.bar).toEqual({ + style: 'solid', + width: (5 / 8) * (96 / 72), + color: '#FF00FF', + space: 2, + }); }); it('should normalize partial borders', () => { @@ -580,6 +587,17 @@ describe('normalizeParagraphBorders', () => { expect(result).toBeDefined(); expect(result?.between).toEqual({ style: 'none' }); }); + + it('should drop bar none/nil borders while keeping between none semantics isolated', () => { + const input = { + bar: { val: 'none' }, + between: { val: 'none' }, + }; + const result = normalizeParagraphBorders(input); + expect(result).toBeDefined(); + expect(result?.bar).toBeUndefined(); + expect(result?.between).toEqual({ style: 'none' }); + }); }); describe('invalid inputs', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.ts b/packages/layout-engine/pm-adapter/src/attributes/borders.ts index c1a78ba32f..cc0d4a34d6 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/borders.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/borders.ts @@ -306,7 +306,7 @@ export function extractCellPadding(cellAttrs: Record): BoxSpaci /** * Normalizes paragraph borders from raw OOXML attributes. * - * Processes border specifications for all four sides (top, right, bottom, left) + * Processes border specifications for paragraph sides (top, right, bottom, left, bar, between) * and converts them to the layout engine's paragraph border format. * * @param value - Raw OOXML borders object with properties for each side @@ -324,7 +324,14 @@ export function extractCellPadding(cellAttrs: Record): BoxSpaci export const normalizeParagraphBorders = (value: unknown): ParagraphAttrs['borders'] | undefined => { if (!value || typeof value !== 'object') return undefined; const source = value as Record; - const sides: Array<'top' | 'right' | 'bottom' | 'left' | 'between'> = ['top', 'right', 'bottom', 'left', 'between']; + const sides: Array<'top' | 'right' | 'bottom' | 'left' | 'bar' | 'between'> = [ + 'top', + 'right', + 'bottom', + 'left', + 'bar', + 'between', + ]; const borders: ParagraphAttrs['borders'] = {}; sides.forEach((side) => {