Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { isRtlParagraph, resolveTextAlign, applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js';
Original file line number Diff line number Diff line change
@@ -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;
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 @@ -5551,6 +5551,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
36 changes: 16 additions & 20 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).
Expand All @@ -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<number, LineSegment[]>();
line.segments.forEach((segment) => {
segments.forEach((segment) => {
const list = segmentsByRun.get(segment.runIndex);
if (list) {
list.push(segment);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown>).dropCap) {
element.classList.add('sd-editor-dropcap');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
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
23 changes: 10 additions & 13 deletions packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts
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