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/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 2fa0241935..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. @@ -5180,16 +5185,8 @@ export class DomPainter { if (styleId) { el.setAttribute('styleid', styleId); } - const alignment = (block.attrs as ParagraphAttrs | undefined)?.alignment; - - // Apply text-align for center/right immediately. - // For justify, we keep 'left' and apply spacing via word-spacing. - if (alignment === 'center' || alignment === 'right') { - el.style.textAlign = alignment; - } else { - // Default to 'left' for 'left', 'justify', 'both', and undefined - el.style.textAlign = 'left'; - } + const pAttrs = block.attrs as ParagraphAttrs | undefined; + const isRtl = applyRtlStyles(el, pAttrs); if (lineRange.pmStart != null) { el.dataset.pmStart = String(lineRange.pmStart); @@ -5443,10 +5440,11 @@ 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 (shouldUseSegmentPositioning(hasExplicitPositioning ?? false, Boolean(line.segments), isRtl)) { + // Use segment-based rendering with absolute positioning for tab-aligned text. + // 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 +5466,10 @@ 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 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); @@ -5555,7 +5555,6 @@ export class DomPainter { geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; } - // Adjust element left to be relative to wrapper elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); this.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); @@ -6985,10 +6984,7 @@ const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs) if (attrs.styleId) { element.setAttribute('styleid', attrs.styleId); } - if (attrs.alignment) { - // Avoid native CSS justify: DomPainter applies justify via per-line word-spacing. - element.style.textAlign = attrs.alignment === 'justify' ? 'left' : attrs.alignment; - } + applyRtlStyles(element, attrs); 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', () => {