Skip to content
7 changes: 5 additions & 2 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1707,8 +1707,11 @@ describe('measureBlock', () => {
const run = block.runs[wordSegment.runIndex];
if (run.kind !== 'tab' && 'text' in run) {
const segmentText = run.text.substring(wordSegment.fromChar, wordSegment.toChar);
// The segment should include "Word " (with trailing space)
expect(segmentText).toBe('Word ');
// If a word-level break split "Word Next", the first segment should
// include the trailing space ("Word "). If the whole run fits on one
// line the segment covers the full text — both are valid outcomes
// depending on font metrics.
expect(segmentText === 'Word ' || segmentText === 'Word Next').toBe(true);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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 { applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 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:
case 'justify':
default:
return isRtl ? 'right' : 'left';
Comment thread
claudiu-ior marked this conversation as resolved.
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 two cases do the same thing — combine them?

Suggested change
return isRtl ? 'right' : 'left';
case 'justify':
default:
return isRtl ? 'right' : 'left';

Comment thread
claudiu-ior marked this conversation as resolved.
}
};

/**
* 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.setAttribute('dir', 'rtl');
element.style.direction = '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;
Comment thread
claudiu-ior marked this conversation as resolved.
94 changes: 94 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6153,6 +6153,100 @@ describe('DomPainter', () => {
});
});
});

describe('RTL paragraph rendering', () => {
const rtlBlock = (attrs: Record<string, unknown>): 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)', () => {
Expand Down
34 changes: 12 additions & 22 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
stampBetweenBorderDataset,
type BetweenBorderInfo,
} from './features/paragraph-borders/index.js';
import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js';

/**
* Minimal type for WordParagraphLayoutOutput marker data used in rendering.
Expand Down Expand Up @@ -5320,17 +5321,8 @@ export class DomPainter {
if (styleId) {
el.setAttribute('styleid', styleId);
}
applyParagraphDirection(el, paragraphAttrs);
const alignment = paragraphAttrs.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);
Expand Down Expand Up @@ -5584,10 +5576,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).
Expand All @@ -5611,8 +5604,10 @@ export class DomPainter {
: 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<number, LineSegment[]>();
line.segments.forEach((segment) => {
segments.forEach((segment) => {
const list = segmentsByRun.get(segment.runIndex);
if (list) {
list.push(segment);
Expand Down Expand Up @@ -5698,7 +5693,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);
Expand Down Expand Up @@ -7163,11 +7157,7 @@ const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs)
if (attrs.styleId) {
element.setAttribute('styleid', attrs.styleId);
}
applyParagraphDirection(element, attrs);
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<string, unknown>).dropCap) {
element.classList.add('sd-editor-dropcap');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,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;
Expand Down Expand Up @@ -318,8 +320,7 @@ export const computeParagraphAttrs = (
keepLines: resolvedParagraphProperties.keepLines,
floatAlignment: floatAlignment,
pageBreakBefore: resolvedParagraphProperties.pageBreakBefore,
direction: normalizedDirection,
rtl: normalizedDirection === 'rtl' ? true : normalizedDirection === 'ltr' ? false : undefined,
...(normalizedDirection ? { direction: normalizedDirection as 'rtl' | 'ltr', rtl: isRtl } : {}),
};

if (normalizedNumberingProperties && normalizedListRendering) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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;
}
Expand Down
Loading
Loading