From 36824e438cb92e816cbee39b2bd666552445e0d2 Mon Sep 17 00:00:00 2001 From: Claudiu Iorgulescu Date: Tue, 10 Mar 2026 15:13:47 +0000 Subject: [PATCH 1/4] Added fixes to properly align paragraphs when loading RTL documents --- .../painters/dom/src/renderer.ts | 56 ++++++++++++++---- .../pm-adapter/src/attributes/paragraph.ts | 5 +- .../src/attributes/spacing-indent.test.ts | 22 ++++++- .../src/attributes/spacing-indent.ts | 23 ++++---- .../pm-adapter/src/index.test.ts | 58 ++++++++++++++++--- 5 files changed, 130 insertions(+), 34 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2fa0241935..95c5f9c230 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5180,14 +5180,27 @@ export class DomPainter { if (styleId) { el.setAttribute('styleid', styleId); } - const alignment = (block.attrs as ParagraphAttrs | undefined)?.alignment; + const pAttrs = block.attrs as ParagraphAttrs | undefined; + const alignment = pAttrs?.alignment; + const isRtl = pAttrs?.direction === 'rtl' || pAttrs?.rtl; - // Apply text-align for center/right immediately. - // For justify, we keep 'left' and apply spacing via word-spacing. + if (isRtl) { + el.dir = 'rtl'; + } + + // Apply text-align based on alignment and direction. + // For justify, DomPainter applies spacing via word-spacing; set the base + // text-align to match the paragraph's natural direction so the last line + // (which isn't stretched) aligns correctly. if (alignment === 'center' || alignment === 'right') { el.style.textAlign = alignment; + } else if (alignment === 'left') { + el.style.textAlign = 'left'; + } else if (alignment === 'justify') { + el.style.textAlign = isRtl ? 'right' : 'left'; + } else if (isRtl) { + el.style.textAlign = 'right'; } else { - // Default to 'left' for 'left', 'justify', 'both', and undefined el.style.textAlign = 'left'; } @@ -5450,6 +5463,8 @@ export class DomPainter { // // The segment x positions from layout are relative to the content area (left margin = 0). // We need to add the paragraph indent to ALL positions (both explicit and calculated). + // For RTL paragraphs, position from the right edge instead of left. + const useRightPositioning = isRtl; const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; const indentLeft = paraIndent?.left ?? 0; const firstLine = paraIndent?.firstLine ?? 0; @@ -5468,6 +5483,14 @@ export class DomPainter { const listIndentOffset = isFirstLineOfPara ? (rawTextStartPx ?? indentLeft) : indentLeft; const indentOffset = isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning + + const setHorizontalPos = (elem: HTMLElement, xPx: number) => { + if (useRightPositioning) { + elem.style.right = `${xPx}px`; + } else { + elem.style.left = `${xPx}px`; + } + }; const segmentsByRun = new Map(); line.segments.forEach((segment) => { const list = segmentsByRun.get(segment.runIndex); @@ -5551,12 +5574,12 @@ export class DomPainter { geoSdtWrapperLeft = elemLeftPx; geoSdtMaxRight = elemLeftPx; geoSdtWrapper.style.position = 'absolute'; - geoSdtWrapper.style.left = `${elemLeftPx}px`; + setHorizontalPos(geoSdtWrapper, elemLeftPx); geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; } - // Adjust element left to be relative to wrapper - elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; + // Adjust element position to be relative to wrapper + setHorizontalPos(elem, elemLeftPx - geoSdtWrapperLeft); geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); this.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); geoSdtWrapper.appendChild(elem); @@ -5582,7 +5605,7 @@ export class DomPainter { const tabEl = this.doc!.createElement('span'); tabEl.style.position = 'absolute'; - tabEl.style.left = `${tabStartX + indentOffset}px`; + setHorizontalPos(tabEl, tabStartX + indentOffset); tabEl.style.top = '0px'; tabEl.style.width = `${actualTabWidth}px`; tabEl.style.height = `${line.lineHeight}px`; @@ -5641,7 +5664,7 @@ export class DomPainter { const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : elem.offsetWidth) ?? 0; elem.style.position = 'absolute'; - elem.style.left = `${segX}px`; + setHorizontalPos(elem, segX); appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } @@ -5672,7 +5695,7 @@ export class DomPainter { const segX = baseSegX + indentOffset; const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : 0) ?? 0; elem.style.position = 'absolute'; - elem.style.left = `${segX}px`; + setHorizontalPos(elem, segX); appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } @@ -5719,7 +5742,7 @@ export class DomPainter { const xPos = baseX + indentOffset; elem.style.position = 'absolute'; - elem.style.left = `${xPos}px`; + setHorizontalPos(elem, xPos); appendToLineGeo(elem, segmentRun, xPos, segment.width ?? 0); // Update cumulative X for next segment by measuring this element's width @@ -6985,9 +7008,18 @@ const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs) if (attrs.styleId) { element.setAttribute('styleid', attrs.styleId); } + const isRtl = attrs.direction === 'rtl' || attrs.rtl; + if (isRtl) { + element.dir = 'rtl'; + } if (attrs.alignment) { // Avoid native CSS justify: DomPainter applies justify via per-line word-spacing. - element.style.textAlign = attrs.alignment === 'justify' ? 'left' : attrs.alignment; + // For RTL justified text, base text-align must be 'right' so the last line aligns correctly. + if (attrs.alignment === 'justify') { + element.style.textAlign = isRtl ? 'right' : 'left'; + } else { + element.style.textAlign = attrs.alignment; + } } if ((attrs as Record).dropCap) { element.classList.add('sd-editor-dropcap'); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index c62d0459c9..95a81bd466 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -243,13 +243,15 @@ export const computeParagraphAttrs = ( ); } + const isRtl = resolvedParagraphProperties.rightToLeft === true; + const normalizedSpacing = normalizeParagraphSpacing( resolvedParagraphProperties.spacing, Boolean(resolvedParagraphProperties.numberingProperties), ); const normalizedIndent = normalizeIndentTwipsToPx(resolvedParagraphProperties.indent as ParagraphIndent); const normalizedTabStops = normalizeOoxmlTabs(resolvedParagraphProperties.tabStops); - const normalizedAlignment = normalizeAlignment(resolvedParagraphProperties.justification); + const normalizedAlignment = normalizeAlignment(resolvedParagraphProperties.justification, isRtl); const normalizedBorders = normalizeParagraphBorders(resolvedParagraphProperties.borders); const normalizedShading = normalizeParagraphShading(resolvedParagraphProperties.shading); const paragraphDecimalSeparator = DEFAULT_DECIMAL_SEPARATOR; @@ -284,6 +286,7 @@ export const computeParagraphAttrs = ( keepLines: resolvedParagraphProperties.keepLines, floatAlignment: floatAlignment, pageBreakBefore: resolvedParagraphProperties.pageBreakBefore, + ...(isRtl ? { direction: 'rtl' as const, rtl: true } : {}), }; if (normalizedNumberingProperties && normalizedListRendering) { diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts index 629e405e11..0ad57727a7 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts @@ -102,9 +102,29 @@ describe('normalizeAlignment', () => { expect(normalizeAlignment('justify')).toBe('justify'); }); - it('maps start/end to left/right', () => { + it('maps start/end to left/right in LTR', () => { expect(normalizeAlignment('start')).toBe('left'); expect(normalizeAlignment('end')).toBe('right'); + expect(normalizeAlignment('start', false)).toBe('left'); + expect(normalizeAlignment('end', false)).toBe('right'); + }); + + it('maps start/end to right/left in RTL', () => { + expect(normalizeAlignment('start', true)).toBe('right'); + expect(normalizeAlignment('end', true)).toBe('left'); + }); + + it('does not flip explicit left/right/center/justify in RTL', () => { + expect(normalizeAlignment('left', true)).toBe('left'); + expect(normalizeAlignment('right', true)).toBe('right'); + expect(normalizeAlignment('center', true)).toBe('center'); + expect(normalizeAlignment('justify', true)).toBe('justify'); + }); + + it('maps Arabic kashida justify variants to justify', () => { + expect(normalizeAlignment('lowKashida')).toBe('justify'); + expect(normalizeAlignment('mediumKashida')).toBe('justify'); + expect(normalizeAlignment('highKashida')).toBe('justify'); }); it('returns undefined for invalid values', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index 7e4f98c9a2..426e10ad03 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -28,25 +28,19 @@ const AUTO_SPACING_LINE_DEFAULT = 240; // Default OOXML auto line spacing in twi * Normalizes paragraph alignment values from OOXML format. * * Maps OOXML alignment values to standard alignment format. Case-sensitive. - * Converts 'start'/'end' to 'left'/'right'. Unknown values return undefined. + * Converts 'start'/'end' to physical directions based on paragraph direction: + * - LTR: start→left, end→right + * - RTL: start→right, end→left * * IMPORTANT: 'left' must return 'left' (not undefined) so that explicit left alignment * from paragraph properties can override style-based center/right alignment. * * @param value - OOXML alignment value ('center', 'right', 'justify', 'start', 'end', 'left') + * @param isRtl - Whether the paragraph is right-to-left * @returns Normalized alignment value, or undefined if invalid - * - * @example - * ```typescript - * normalizeAlignment('center'); // 'center' - * normalizeAlignment('left'); // 'left' - * normalizeAlignment('start'); // 'left' - * normalizeAlignment('end'); // 'right' - * normalizeAlignment('CENTER'); // undefined (case-sensitive) - * ``` */ -export const normalizeAlignment = (value: unknown): ParagraphAttrs['alignment'] => { +export const normalizeAlignment = (value: unknown, isRtl = false): ParagraphAttrs['alignment'] => { switch (value) { case 'center': case 'right': @@ -57,11 +51,14 @@ export const normalizeAlignment = (value: unknown): ParagraphAttrs['alignment'] case 'distribute': case 'numTab': case 'thaiDistribute': + case 'lowKashida': + case 'mediumKashida': + case 'highKashida': return 'justify'; case 'end': - return 'right'; + return isRtl ? 'left' : 'right'; case 'start': - return 'left'; + return isRtl ? 'right' : 'left'; default: return undefined; } diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index ff9f5a3dba..079edd4eb1 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3025,8 +3025,8 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBeUndefined(); - expect(paragraph.attrs?.rtl).toBeUndefined(); + expect(paragraph.attrs?.direction).toBe('rtl'); + expect(paragraph.attrs?.rtl).toBe(true); expect(paragraph.attrs?.indent?.left).toBe(24); expect(paragraph.attrs?.indent?.right).toBe(12); }); @@ -3936,7 +3936,7 @@ describe('toFlowBlocks', () => { }); describe('bidi alignment fallback', () => { - it('defaults RTL paragraphs to right alignment when no explicit alignment', () => { + it('defaults RTL paragraphs to no explicit alignment (renderer defaults to right)', () => { const pmDoc = { type: 'doc', content: [ @@ -3960,9 +3960,9 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); - expect(blocks[0].attrs).toMatchObject({ - alignment: undefined, - }); + expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.rtl).toBe(true); + expect(blocks[0].attrs?.alignment).toBeUndefined(); }); it('respects explicit alignment on RTL paragraphs', () => { @@ -3990,12 +3990,14 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); + expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.rtl).toBe(true); expect(blocks[0].attrs).toMatchObject({ alignment: 'center', }); }); - it('adjustRightInd overrides alignment to right', () => { + it('preserves explicit left alignment on RTL paragraphs', () => { const pmDoc = { type: 'doc', content: [ @@ -4021,10 +4023,52 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); + expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.rtl).toBe(true); expect(blocks[0].attrs).toMatchObject({ alignment: 'left', }); }); + + it('maps start to right and end to left for RTL paragraphs', () => { + const pmDocStart = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'start', + }, + }, + content: [{ type: 'text', text: 'مرحبا' }], + }, + ], + }; + + const pmDocEnd = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'end', + }, + }, + content: [{ type: 'text', text: 'مرحبا' }], + }, + ], + }; + + const { blocks: blocksStart } = toFlowBlocks(pmDocStart); + const { blocks: blocksEnd } = toFlowBlocks(pmDocEnd); + + expect(blocksStart[0].attrs?.alignment).toBe('right'); + expect(blocksEnd[0].attrs?.alignment).toBe('left'); + }); }); describe('documentSection SDT metadata propagation', () => { From 9795ed10333a5ccd936ad5c6019b1e05a2ef29bc Mon Sep 17 00:00:00 2001 From: Claudiu Iorgulescu Date: Wed, 11 Mar 2026 09:17:30 +0000 Subject: [PATCH 2/4] P2 Mirror tab decorations with RTL segment positioning. --- packages/layout-engine/painters/dom/src/renderer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 95c5f9c230..5ad51216ad 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5233,7 +5233,11 @@ export class DomPainter { leaderEl.classList.add('superdoc-leader'); leaderEl.setAttribute('data-style', ld.style); leaderEl.style.position = 'absolute'; - leaderEl.style.left = `${ld.from}px`; + if (isRtl) { + leaderEl.style.right = `${ld.from}px`; + } else { + leaderEl.style.left = `${ld.from}px`; + } leaderEl.style.width = `${Math.max(0, ld.to - ld.from)}px`; // Align leaders closer to the text baseline using measured descent const baselineOffset = Math.max(1, Math.round(Math.max(1, line.descent * 0.5))); @@ -5263,7 +5267,11 @@ export class DomPainter { const barEl = this.doc!.createElement('div'); barEl.classList.add('superdoc-tab-bar'); barEl.style.position = 'absolute'; - barEl.style.left = `${bar.x}px`; + if (isRtl) { + barEl.style.right = `${bar.x}px`; + } else { + barEl.style.left = `${bar.x}px`; + } barEl.style.top = '0px'; barEl.style.bottom = '0px'; barEl.style.width = '1px'; From 5bca37ed85ff8c97d16c13044f25315f71a03be8 Mon Sep 17 00:00:00 2001 From: Claudiu Iorgulescu Date: Wed, 11 Mar 2026 10:30:26 +0000 Subject: [PATCH 3/4] addressing pr feedback --- .../painters/dom/src/index.test.ts | 94 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 89 +++++++----------- 2 files changed, 128 insertions(+), 55 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 660f8e0d9c..ef85fff236 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5551,6 +5551,100 @@ describe('DomPainter', () => { }); }); }); + + describe('RTL paragraph rendering', () => { + const rtlBlock = (attrs: Record): FlowBlock => ({ + kind: 'paragraph', + id: 'rtl-block', + runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16 }], + attrs: { direction: 'rtl' as const, rtl: true, ...attrs }, + }); + + const rtlMeasure: Measure = { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 80, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }; + + const rtlLayout: Layout = { + pageSize: { w: 300, h: 200 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'rtl-block', fromLine: 0, toLine: 1, x: 0, y: 0, width: 200 }, + ], + }, + ], + }; + + it('sets dir="rtl" and defaults text-align to right', () => { + const painter = createDomPainter({ blocks: [rtlBlock({})], measures: [rtlMeasure] }); + painter.paint(rtlLayout, mount); + + const line = mount.querySelector('.superdoc-line') as HTMLElement; + expect(line.dir).toBe('rtl'); + expect(line.style.textAlign).toBe('right'); + }); + + it('preserves explicit left alignment on RTL paragraphs', () => { + const painter = createDomPainter({ blocks: [rtlBlock({ alignment: 'left' })], measures: [rtlMeasure] }); + painter.paint(rtlLayout, mount); + + const line = mount.querySelector('.superdoc-line') as HTMLElement; + expect(line.dir).toBe('rtl'); + expect(line.style.textAlign).toBe('left'); + }); + + it('uses text-align right for RTL justified paragraphs', () => { + const painter = createDomPainter({ blocks: [rtlBlock({ alignment: 'justify' })], measures: [rtlMeasure] }); + painter.paint(rtlLayout, mount); + + const line = mount.querySelector('.superdoc-line') as HTMLElement; + expect(line.dir).toBe('rtl'); + expect(line.style.textAlign).toBe('right'); + }); + + it('does not use absolute positioning for RTL lines with tab segments', () => { + const tabBlock: FlowBlock = { + kind: 'paragraph', + id: 'rtl-block', + runs: [ + { text: 'مرحبا', fontFamily: 'Arial', fontSize: 16 }, + { kind: 'tab', width: 40, fontFamily: 'Arial', fontSize: 16 } as any, + { text: 'عالم', fontFamily: 'Arial', fontSize: 16 }, + ], + attrs: { direction: 'rtl' as const, rtl: true }, + }; + + const tabMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, fromChar: 0, toRun: 2, toChar: 4, + width: 160, ascent: 12, descent: 4, lineHeight: 20, + segments: [ + { runIndex: 0, fromChar: 0, toChar: 5, width: 60 }, + { runIndex: 1, fromChar: 0, toChar: 0, width: 40, x: 60 }, + { runIndex: 2, fromChar: 0, toChar: 4, width: 60, x: 100 }, + ], + }, + ], + totalHeight: 20, + }; + + const painter = createDomPainter({ blocks: [tabBlock], measures: [tabMeasure] }); + painter.paint(rtlLayout, mount); + + const line = mount.querySelector('.superdoc-line') as HTMLElement; + expect(line.dir).toBe('rtl'); + const spans = Array.from(line.querySelectorAll('span')); + const hasAbsolute = spans.some((s) => s.style.position === 'absolute'); + expect(hasAbsolute).toBe(false); + }); + }); }); describe('ImageFragment (block-level images)', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5ad51216ad..8acbbf91e1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5187,22 +5187,7 @@ export class DomPainter { if (isRtl) { el.dir = 'rtl'; } - - // Apply text-align based on alignment and direction. - // For justify, DomPainter applies spacing via word-spacing; set the base - // text-align to match the paragraph's natural direction so the last line - // (which isn't stretched) aligns correctly. - if (alignment === 'center' || alignment === 'right') { - el.style.textAlign = alignment; - } else if (alignment === 'left') { - el.style.textAlign = 'left'; - } else if (alignment === 'justify') { - el.style.textAlign = isRtl ? 'right' : 'left'; - } else if (isRtl) { - el.style.textAlign = 'right'; - } else { - el.style.textAlign = 'left'; - } + el.style.textAlign = resolveTextAlign(alignment, isRtl ?? false); if (lineRange.pmStart != null) { el.dataset.pmStart = String(lineRange.pmStart); @@ -5233,11 +5218,7 @@ export class DomPainter { leaderEl.classList.add('superdoc-leader'); leaderEl.setAttribute('data-style', ld.style); leaderEl.style.position = 'absolute'; - if (isRtl) { - leaderEl.style.right = `${ld.from}px`; - } else { - leaderEl.style.left = `${ld.from}px`; - } + leaderEl.style.left = `${ld.from}px`; leaderEl.style.width = `${Math.max(0, ld.to - ld.from)}px`; // Align leaders closer to the text baseline using measured descent const baselineOffset = Math.max(1, Math.round(Math.max(1, line.descent * 0.5))); @@ -5267,11 +5248,7 @@ export class DomPainter { const barEl = this.doc!.createElement('div'); barEl.classList.add('superdoc-tab-bar'); barEl.style.position = 'absolute'; - if (isRtl) { - barEl.style.right = `${bar.x}px`; - } else { - barEl.style.left = `${bar.x}px`; - } + barEl.style.left = `${bar.x}px`; barEl.style.top = '0px'; barEl.style.bottom = '0px'; barEl.style.width = '1px'; @@ -5464,15 +5441,14 @@ export class DomPainter { el.style.wordSpacing = `${spacingPerSpace}px`; } - if (hasExplicitPositioning && line.segments) { - // Use segment-based rendering with absolute positioning for tab-aligned text - // When rendering segments, we need to track cumulative X position - // for segments that don't have explicit X coordinates. + if (hasExplicitPositioning && line.segments && !isRtl) { + // Use segment-based rendering with absolute positioning for tab-aligned text. + // Skipped for RTL: the layout engine computes tab X positions in LTR order, + // so for RTL paragraphs we fall through to inline-flow rendering where the + // browser's native bidi algorithm handles tab positioning via dir="rtl". // // The segment x positions from layout are relative to the content area (left margin = 0). // We need to add the paragraph indent to ALL positions (both explicit and calculated). - // For RTL paragraphs, position from the right edge instead of left. - const useRightPositioning = isRtl; const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; const indentLeft = paraIndent?.left ?? 0; const firstLine = paraIndent?.firstLine ?? 0; @@ -5492,13 +5468,6 @@ export class DomPainter { const indentOffset = isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning - const setHorizontalPos = (elem: HTMLElement, xPx: number) => { - if (useRightPositioning) { - elem.style.right = `${xPx}px`; - } else { - elem.style.left = `${xPx}px`; - } - }; const segmentsByRun = new Map(); line.segments.forEach((segment) => { const list = segmentsByRun.get(segment.runIndex); @@ -5582,12 +5551,11 @@ export class DomPainter { geoSdtWrapperLeft = elemLeftPx; geoSdtMaxRight = elemLeftPx; geoSdtWrapper.style.position = 'absolute'; - setHorizontalPos(geoSdtWrapper, elemLeftPx); + geoSdtWrapper.style.left = `${elemLeftPx}px`; geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; } - // Adjust element position to be relative to wrapper - setHorizontalPos(elem, elemLeftPx - geoSdtWrapperLeft); + elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); this.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); geoSdtWrapper.appendChild(elem); @@ -5613,7 +5581,7 @@ export class DomPainter { const tabEl = this.doc!.createElement('span'); tabEl.style.position = 'absolute'; - setHorizontalPos(tabEl, tabStartX + indentOffset); + tabEl.style.left = `${tabStartX + indentOffset}px`; tabEl.style.top = '0px'; tabEl.style.width = `${actualTabWidth}px`; tabEl.style.height = `${line.lineHeight}px`; @@ -5672,7 +5640,7 @@ export class DomPainter { const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : elem.offsetWidth) ?? 0; elem.style.position = 'absolute'; - setHorizontalPos(elem, segX); + elem.style.left = `${segX}px`; appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } @@ -5703,7 +5671,7 @@ export class DomPainter { const segX = baseSegX + indentOffset; const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : 0) ?? 0; elem.style.position = 'absolute'; - setHorizontalPos(elem, segX); + elem.style.left = `${segX}px`; appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } @@ -5750,7 +5718,7 @@ export class DomPainter { const xPos = baseX + indentOffset; elem.style.position = 'absolute'; - setHorizontalPos(elem, xPos); + elem.style.left = `${xPos}px`; appendToLineGeo(elem, segmentRun, xPos, segment.width ?? 0); // Update cumulative X for next segment by measuring this element's width @@ -7011,6 +6979,25 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record< }); }; +/** + * Compute the effective CSS text-align for a paragraph given its alignment + * attribute and direction. DomPainter handles justify via per-line + * word-spacing, so 'justify' becomes 'left' (LTR) or 'right' (RTL) to + * align the last line correctly. + */ +const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl: boolean): string => { + switch (alignment) { + case 'center': + case 'right': + case 'left': + return alignment; + case 'justify': + return isRtl ? 'right' : 'left'; + default: + return isRtl ? 'right' : 'left'; + } +}; + const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { if (!attrs) return; if (attrs.styleId) { @@ -7020,15 +7007,7 @@ const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs) if (isRtl) { element.dir = 'rtl'; } - if (attrs.alignment) { - // Avoid native CSS justify: DomPainter applies justify via per-line word-spacing. - // For RTL justified text, base text-align must be 'right' so the last line aligns correctly. - if (attrs.alignment === 'justify') { - element.style.textAlign = isRtl ? 'right' : 'left'; - } else { - element.style.textAlign = attrs.alignment; - } - } + element.style.textAlign = resolveTextAlign(attrs.alignment, isRtl ?? false); if ((attrs as Record).dropCap) { element.classList.add('sd-editor-dropcap'); } From d04acad11a0e229304ca8d065df81fe6087a8606 Mon Sep 17 00:00:00 2001 From: Claudiu Iorgulescu Date: Thu, 12 Mar 2026 17:00:56 +0000 Subject: [PATCH 4/4] New module: features/rtl-paragraph/ following the same pattern as paragraph-borders/ --- .../dom/src/features/feature-registry.ts | 9 +++ .../dom/src/features/rtl-paragraph/index.ts | 15 +++++ .../src/features/rtl-paragraph/rtl-styles.ts | 64 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 49 ++++---------- 4 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/rtl-paragraph/index.ts create mode 100644 packages/layout-engine/painters/dom/src/features/rtl-paragraph/rtl-styles.ts diff --git a/packages/layout-engine/painters/dom/src/features/feature-registry.ts b/packages/layout-engine/painters/dom/src/features/feature-registry.ts index cf60bad70a..83d6056a38 100644 --- a/packages/layout-engine/painters/dom/src/features/feature-registry.ts +++ b/packages/layout-engine/painters/dom/src/features/feature-registry.ts @@ -31,4 +31,13 @@ export const RENDERING_FEATURES = { handles: ['w:shd/@w:fill', 'w:shd/@w:val', 'w:shd/@w:color'], spec: '§17.3.1.31', }, + + // ─── RTL Paragraph ───────────────────────────────────────────── + // @spec ECMA-376 §17.3.1.1 (bidi), §17.3.2.30 (rtl) + 'w:bidi': { + feature: 'rtl-paragraph', + module: './rtl-paragraph', + handles: ['w:pPr/w:bidi', 'w:rPr/w:rtl'], + spec: '§17.3.1.1', + }, } as const; diff --git a/packages/layout-engine/painters/dom/src/features/rtl-paragraph/index.ts b/packages/layout-engine/painters/dom/src/features/rtl-paragraph/index.ts new file mode 100644 index 0000000000..dc1c99a80d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/rtl-paragraph/index.ts @@ -0,0 +1,15 @@ +/** + * RTL Paragraph — rendering feature module + * + * Centralises all right-to-left paragraph logic used by DomPainter: + * - Detecting whether a paragraph is RTL + * - Applying dir="rtl" and the correct text-align to an element + * - Resolving text-align for RTL vs LTR (justify → right/left) + * - Deciding whether segment-based (absolute) positioning is safe + * + * @ooxml w:pPr/w:bidi — paragraph bidirectional flag + * @ooxml w:rPr/w:rtl — run-level right-to-left flag + * @spec ECMA-376 §17.3.1.1 (bidi), §17.3.2.30 (rtl) + */ + +export { isRtlParagraph, resolveTextAlign, applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js'; diff --git a/packages/layout-engine/painters/dom/src/features/rtl-paragraph/rtl-styles.ts b/packages/layout-engine/painters/dom/src/features/rtl-paragraph/rtl-styles.ts new file mode 100644 index 0000000000..3d08dfe0c4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/rtl-paragraph/rtl-styles.ts @@ -0,0 +1,64 @@ +/** + * RTL paragraph style helpers for DomPainter. + * + * All RTL-aware rendering decisions live here so the main renderer + * doesn't need to re-derive direction in multiple places. + * + * @ooxml w:pPr/w:bidi — paragraph bidirectional flag + * @spec ECMA-376 §17.3.1.1 (bidi) + */ +import type { ParagraphAttrs } from '@superdoc/contracts'; + +/** + * Returns true when the paragraph attributes indicate right-to-left direction. + * Checks both the `direction` string and the legacy `rtl` boolean flag. + */ +export const isRtlParagraph = (attrs: ParagraphAttrs | undefined): boolean => + attrs?.direction === 'rtl' || attrs?.rtl === true; + +/** + * Compute the effective CSS text-align for a paragraph. + * + * DomPainter handles justify via per-line word-spacing, so 'justify' + * becomes 'left' (LTR) or 'right' (RTL) to align the last line correctly. + * When no explicit alignment is set the default follows the paragraph direction. + */ +export const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl: boolean): string => { + switch (alignment) { + case 'center': + case 'right': + case 'left': + return alignment; + case 'justify': + return isRtl ? 'right' : 'left'; + default: + return isRtl ? 'right' : 'left'; + } +}; + +/** + * Apply `dir` and `text-align` to an element based on paragraph attributes. + * Used by both `renderLine` (line elements) and `applyParagraphBlockStyles` + * (fragment wrappers) so the logic stays in one place. + */ +export const applyRtlStyles = (element: HTMLElement, attrs: ParagraphAttrs | undefined): boolean => { + const rtl = isRtlParagraph(attrs); + if (rtl) { + element.dir = 'rtl'; + } + element.style.textAlign = resolveTextAlign(attrs?.alignment, rtl); + return rtl; +}; + +/** + * Whether the renderer should use absolute-positioned segment layout for a line. + * + * Returns false for RTL paragraphs: the layout engine computes tab X positions + * in LTR order, so for RTL we fall through to inline-flow rendering where the + * browser's native bidi algorithm handles tab positioning via dir="rtl". + */ +export const shouldUseSegmentPositioning = ( + hasExplicitPositioning: boolean, + hasSegments: boolean, + isRtl: boolean, +): boolean => hasExplicitPositioning && hasSegments && !isRtl; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8acbbf91e1..efafdc609b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -104,6 +104,11 @@ import { stampBetweenBorderDataset, type BetweenBorderInfo, } from './features/paragraph-borders/index.js'; +import { + isRtlParagraph, + applyRtlStyles, + shouldUseSegmentPositioning, +} from './features/rtl-paragraph/index.js'; /** * Minimal type for WordParagraphLayoutOutput marker data used in rendering. @@ -5181,13 +5186,7 @@ export class DomPainter { el.setAttribute('styleid', styleId); } const pAttrs = block.attrs as ParagraphAttrs | undefined; - const alignment = pAttrs?.alignment; - const isRtl = pAttrs?.direction === 'rtl' || pAttrs?.rtl; - - if (isRtl) { - el.dir = 'rtl'; - } - el.style.textAlign = resolveTextAlign(alignment, isRtl ?? false); + const isRtl = applyRtlStyles(el, pAttrs); if (lineRange.pmStart != null) { el.dataset.pmStart = String(lineRange.pmStart); @@ -5441,11 +5440,11 @@ export class DomPainter { el.style.wordSpacing = `${spacingPerSpace}px`; } - if (hasExplicitPositioning && line.segments && !isRtl) { + if (shouldUseSegmentPositioning(hasExplicitPositioning ?? false, Boolean(line.segments), isRtl)) { // Use segment-based rendering with absolute positioning for tab-aligned text. - // Skipped for RTL: the layout engine computes tab X positions in LTR order, - // so for RTL paragraphs we fall through to inline-flow rendering where the - // browser's native bidi algorithm handles tab positioning via dir="rtl". + // shouldUseSegmentPositioning returns false for RTL because the layout engine + // computes tab positions in LTR order; RTL lines fall through to inline-flow + // rendering where dir="rtl" lets the browser handle tab positioning. // // The segment x positions from layout are relative to the content area (left margin = 0). // We need to add the paragraph indent to ALL positions (both explicit and calculated). @@ -5468,8 +5467,9 @@ export class DomPainter { const indentOffset = isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning + const segments = line.segments!; const segmentsByRun = new Map(); - line.segments.forEach((segment) => { + segments.forEach((segment) => { const list = segmentsByRun.get(segment.runIndex); if (list) { list.push(segment); @@ -6979,35 +6979,12 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record< }); }; -/** - * Compute the effective CSS text-align for a paragraph given its alignment - * attribute and direction. DomPainter handles justify via per-line - * word-spacing, so 'justify' becomes 'left' (LTR) or 'right' (RTL) to - * align the last line correctly. - */ -const resolveTextAlign = (alignment: ParagraphAttrs['alignment'], isRtl: boolean): string => { - switch (alignment) { - case 'center': - case 'right': - case 'left': - return alignment; - case 'justify': - return isRtl ? 'right' : 'left'; - default: - return isRtl ? 'right' : 'left'; - } -}; - const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { if (!attrs) return; if (attrs.styleId) { element.setAttribute('styleid', attrs.styleId); } - const isRtl = attrs.direction === 'rtl' || attrs.rtl; - if (isRtl) { - element.dir = 'rtl'; - } - element.style.textAlign = resolveTextAlign(attrs.alignment, isRtl ?? false); + applyRtlStyles(element, attrs); if ((attrs as Record).dropCap) { element.classList.add('sd-editor-dropcap'); }