diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b2b2513dc..a36f8495f 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3810,6 +3810,70 @@ describe('DomPainter', () => { expect(borderLayer.style.borderLeftColor).toBe('rgb(0, 255, 0)'); }); + it('renders borders extending into margins without pushing content', () => { + const blockWithBorderSpacing: FlowBlock = { + kind: 'paragraph', + id: 'border-space-block', + attrs: { + borders: { + top: { style: 'solid', width: 2, color: '#ff0000', space: 5 }, + bottom: { style: 'solid', width: 3, color: '#00ff00', space: 10 }, + left: { style: 'dashed', width: 1, color: '#0000ff', space: 4 }, + right: { style: 'dotted', width: 2, color: '#ffff00', space: 6 }, + }, + }, + runs: [{ text: 'Border spacing test', fontFamily: 'Arial', fontSize: 16 }], + }; + + const painter = createDomPainter({ + blocks: [blockWithBorderSpacing], + measures: [measure], + }); + + const borderSpaceLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'border-space-block', + fromLine: 0, + toLine: 1, + x: 50, + y: 60, + width: 260, + }, + ], + }, + ], + }; + + painter.paint(borderSpaceLayout, mount); + + const fragment = mount.querySelector('[data-block-id="border-space-block"]') as HTMLElement; + const borderLayer = fragment.querySelector('.superdoc-paragraph-border') as HTMLElement; + + // Verify fragment has no padding - borders should not push content inward + expect(fragment.style.paddingTop).toBe(''); + expect(fragment.style.paddingBottom).toBe(''); + expect(fragment.style.paddingLeft).toBe(''); + expect(fragment.style.paddingRight).toBe(''); + + // Verify border layer extends into margins with negative offsets + // Top: -(space(5) + width(2)) = -7px + expect(borderLayer.style.top).toBe('-7px'); + // Bottom: -(space(10) + width(3)) = -13px + expect(borderLayer.style.bottom).toBe('-13px'); + // Left offset is added to leftInset, so check the actual CSS value + // The border layer should extend leftward by -(space(4) + width(1)) = -5px + expect(borderLayer.style.left).toBe('-5px'); + // Width should be increased to account for both left and right extensions + // Original width 260 + left extension 5 + right extension 8 = 273 + expect(borderLayer.style.width).toBe('273px'); + }); + it('applies paragraph shading fill to fragment backgrounds', () => { const shadedBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 013b44e3a..e53232864 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2036,6 +2036,10 @@ export class DomPainter { if (fragmentEl.style.marginRight) fragmentEl.style.removeProperty('margin-right'); if (fragmentEl.style.textIndent) fragmentEl.style.removeProperty('text-indent'); + // Note: Paragraph borders should extend into margins, not push content inward. + // Border rendering is handled by the border layer which uses negative positioning + // to extend beyond the fragment bounds. No padding is needed here. + const paraIndent = block.attrs?.indent; const paraIndentLeft = paraIndent?.left ?? 0; const paraIndentRight = paraIndent?.right ?? 0; @@ -5912,6 +5916,39 @@ const createParagraphDecorationLayers = ( ): { shadingLayer?: HTMLElement; borderLayer?: HTMLElement } => { if (!attrs?.borders && !attrs?.shading) return {}; const borderBox = getParagraphBorderBox(fragmentWidth, attrs.indent); + + // Calculate border positioning to extend into margins + // Borders should overflow outside the content area, with 'space' creating a gap between border and text + const borders = attrs.borders; + let topOffset = 0; + let bottomOffset = 0; + let leftOffset = 0; + let rightOffset = 0; + + if (borders) { + // For each border, extend outward by (space + width) so border is drawn in the margin + if (borders.top) { + const space = Math.max(0, borders.top.space ?? 0); + const width = Math.max(0, borders.top.width ?? 1); + topOffset = -(space + width); // Negative to extend upward into margin + } + if (borders.bottom) { + const space = Math.max(0, borders.bottom.space ?? 0); + const width = Math.max(0, borders.bottom.width ?? 1); + bottomOffset = -(space + width); // Negative to extend downward into margin + } + if (borders.left) { + const space = Math.max(0, borders.left.space ?? 0); + const width = Math.max(0, borders.left.width ?? 1); + leftOffset = -(space + width); // Negative to extend left into margin + } + if (borders.right) { + const space = Math.max(0, borders.right.space ?? 0); + const width = Math.max(0, borders.right.width ?? 1); + rightOffset = -(space + width); // Negative to extend right into margin + } + } + const baseStyles = { position: 'absolute', top: '0px', @@ -5936,6 +5973,14 @@ const createParagraphDecorationLayers = ( borderLayer.classList.add('superdoc-paragraph-border'); Object.assign(borderLayer.style, baseStyles); borderLayer.style.zIndex = '1'; + + // Position border layer to extend into margins + // Negative offsets mean the border extends beyond the fragment bounds + borderLayer.style.top = `${topOffset}px`; + borderLayer.style.bottom = `${bottomOffset}px`; + borderLayer.style.left = `${borderBox.leftInset + leftOffset}px`; + borderLayer.style.width = `${borderBox.width - leftOffset - rightOffset}px`; + applyParagraphBorderStyles(borderLayer, attrs.borders); }