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
1 change: 0 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1467,7 +1467,6 @@ export type ParagraphAttrs = {
/** Marks an empty paragraph that only exists to carry section properties. */
sectPrMarker?: boolean;
direction?: 'ltr' | 'rtl';
rtl?: boolean;
isTocEntry?: boolean;
tocInstruction?: string;
/** Floating alignment for positioned paragraphs (from w:framePr/@w:xAlign). */
Expand Down
6 changes: 2 additions & 4 deletions packages/layout-engine/layout-bridge/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,8 @@ const hashRuns = (block: FlowBlock): string => {
if (sh.color) parts.push(`shc:${sh.color}`);
}

// Direction and RTL
// Direction
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
if (attrs.rtl) parts.push('rtl');

if (parts.length > 0) {
cellHashes.push(`pa:${parts.join(':')}`);
Expand Down Expand Up @@ -547,9 +546,8 @@ const hashRuns = (block: FlowBlock): string => {
parts.push(`tb:${tabsHash}`);
}

// Direction and RTL
// Direction
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
if (attrs.rtl) parts.push('rtl');

// Pagination properties
if (attrs.keepNext) parts.push('kn');
Expand Down
1 change: 0 additions & 1 deletion packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,6 @@ const paragraphAttrsEqual = (a?: ParagraphAttrs, b?: ParagraphAttrs): boolean =>
a.keepNext !== b.keepNext ||
a.keepLines !== b.keepLines ||
a.direction !== b.direction ||
a.rtl !== b.rtl ||
a.floatAlignment !== b.floatAlignment
) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,8 @@ export const hashParagraphAttrs = (attrs: ParagraphAttrs | undefined): string =>
if (sh.color) parts.push(`shc:${sh.color}`);
}

// Direction and RTL
// Direction
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
if (attrs.rtl) parts.push('rtl');

return parts.join(':');
};
Expand Down
3 changes: 0 additions & 3 deletions packages/layout-engine/layout-bridge/src/position-hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,6 @@ export const isRtlBlock = (block: FlowBlock): boolean => {
if (typeof directionAttr === 'string' && directionAttr.toLowerCase() === 'rtl') {
return true;
}
if (typeof attrs.rtl === 'boolean') {
return attrs.rtl;
}
return false;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,6 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
attrs.shading?.fill ?? '',
attrs.shading?.color ?? '',
attrs.direction ?? '',
attrs.rtl ? '1' : '',
attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
].join(':')
: '';
Expand Down Expand Up @@ -437,7 +436,6 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
hash = hashString(hash, attrs.shading?.fill ?? '');
hash = hashString(hash, attrs.shading?.color ?? '');
hash = hashString(hash, attrs.direction ?? '');
hash = hashString(hash, attrs.rtl ? '1' : '');
if (attrs.borders) {
hash = hashString(hash, hashParagraphBorders(attrs.borders));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ 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;
export const isRtlParagraph = (attrs: ParagraphAttrs | undefined): boolean => attrs?.direction === 'rtl';

/**
* Compute the effective CSS text-align for a paragraph.
Expand Down Expand Up @@ -45,6 +43,9 @@ export const applyRtlStyles = (element: HTMLElement, attrs: ParagraphAttrs | und
if (rtl) {
element.setAttribute('dir', 'rtl');
element.style.direction = 'rtl';
} else {
element.removeAttribute('dir');
element.style.direction = '';
}
element.style.textAlign = resolveTextAlign(attrs?.alignment, rtl);
return rtl;
Expand Down
5 changes: 2 additions & 3 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4727,7 +4727,6 @@ describe('DomPainter', () => {
attrs: {
alignment: 'center',
direction: 'rtl',
rtl: true,
},
};
const footerMeasure: Measure = {
Expand Down Expand Up @@ -8155,7 +8154,7 @@ describe('DomPainter', () => {
kind: 'paragraph',
id: 'rtl-block',
runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16 }],
attrs: { direction: 'rtl' as const, rtl: true, ...attrs },
attrs: { direction: 'rtl' as const, ...attrs },
});

const rtlMeasure: Measure = {
Expand Down Expand Up @@ -8210,7 +8209,7 @@ describe('DomPainter', () => {
{ kind: 'tab', width: 40, fontFamily: 'Arial', fontSize: 16 } as any,
{ text: 'عالم', fontFamily: 'Arial', fontSize: 16 },
],
attrs: { direction: 'rtl' as const, rtl: true },
attrs: { direction: 'rtl' as const },
};

const tabMeasure: Measure = {
Expand Down
26 changes: 1 addition & 25 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7447,7 +7447,7 @@ const hasListMarkerProperties = (
* - Position markers (pmStart, pmEnd)
* - Special tokens (page numbers, etc.)
* - List marker properties (numId, ilvl, markerText) - for list indent changes
* - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, rtl, tabs)
* - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, tabs)
* - Table cell content and paragraph formatting within cells
*
* For table blocks, a deep hash is computed across all rows and cells, including:
Expand Down Expand Up @@ -7591,7 +7591,6 @@ const deriveBlockVersion = (block: FlowBlock): string => {
attrs.shading?.fill ?? '',
attrs.shading?.color ?? '',
attrs.direction ?? '',
attrs.rtl ? '1' : '',
attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
].join(':')
: '';
Expand Down Expand Up @@ -7778,7 +7777,6 @@ const deriveBlockVersion = (block: FlowBlock): string => {
hash = hashString(hash, attrs.shading?.fill ?? '');
hash = hashString(hash, attrs.shading?.color ?? '');
hash = hashString(hash, attrs.direction ?? '');
hash = hashString(hash, attrs.rtl ? '1' : '');
if (attrs.borders) {
hash = hashString(hash, hashParagraphBorders(attrs.borders));
}
Expand Down Expand Up @@ -8004,28 +8002,6 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record<
});
};

const resolveParagraphDirection = (attrs?: ParagraphAttrs): 'ltr' | 'rtl' | undefined => {
if (attrs?.direction) {
return attrs.direction;
}
if (attrs?.rtl === true) {
return 'rtl';
}
if (attrs?.rtl === false) {
return 'ltr';
}
return undefined;
};

const applyParagraphDirection = (element: HTMLElement, attrs?: ParagraphAttrs): void => {
const direction = resolveParagraphDirection(attrs);
if (!direction) {
return;
}
element.setAttribute('dir', direction);
element.style.direction = direction;
};

const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => {
if (!attrs) return;
if (attrs.styleId) {
Expand Down
113 changes: 112 additions & 1 deletion packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
normalizeFramePr,
normalizeDropCap,
computeParagraphAttrs,
resolveEffectiveParagraphDirection,
computeRunAttrs,
hasExplicitParagraphRunProperties,
} from './paragraph.js';
Expand Down Expand Up @@ -273,7 +274,117 @@ describe('computeParagraphAttrs', () => {
const { paragraphAttrs } = computeParagraphAttrs(paragraph as never);

expect(paragraphAttrs.direction).toBe('rtl');
expect(paragraphAttrs.rtl).toBe(true);
});

it('uses section direction fallback when paragraph direction is not explicit', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {},
},
};

const converterContext = {
sectionDirection: 'rtl',
translatedNumbering: {},
translatedLinkedStyles: { docDefaults: {}, styles: {} },
tableInfo: null,
};

const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
expect(paragraphAttrs.direction).toBe('rtl');
});
});

describe('resolveEffectiveParagraphDirection', () => {
it('prefers resolved paragraph rightToLeft over section direction', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {
rightToLeft: true,
},
},
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, { rightToLeft: true } as never, 'ltr');
expect(direction).toBe('rtl');
});

it('uses section direction when paragraph direction is not explicit', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {},
},
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, 'rtl');
expect(direction).toBe('rtl');
});

it('infers rtl when all runs with explicit direction are rtl', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBe('rtl');
});

it('infers ltr when explicit ltr runs are the majority', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'def' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBe('ltr');
});

it('infers rtl when explicit rtl runs are the majority', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBe('rtl');
});

it('uses first explicit run direction as tie-breaker for mixed runs', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBe('rtl');
});

it('returns undefined when no direction signal exists', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [{ type: 'run', attrs: { runProperties: {} }, content: [{ type: 'text', text: 'plain text' }] }],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});
});

Expand Down
53 changes: 45 additions & 8 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {

const DEFAULT_DECIMAL_SEPARATOR = '.';
const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch
type ParagraphDirection = 'ltr' | 'rtl';

const normalizeColor = (value?: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
Expand All @@ -62,6 +63,43 @@ export const deepClone = <T>(obj: T): T => {
return clone as T;
};

const inferDirectionFromRuns = (para: PMNode): ParagraphDirection | undefined => {
const content = Array.isArray(para.content) ? para.content : [];
let rtlRunCount = 0;
let ltrRunCount = 0;
let firstExplicitDirection: ParagraphDirection | undefined;

for (const node of content) {
if (node?.type !== 'run') continue;
const runDirection = (node.attrs?.runProperties as { rightToLeft?: unknown } | undefined)?.rightToLeft;
if (runDirection === true) {
rtlRunCount += 1;
if (!firstExplicitDirection) firstExplicitDirection = 'rtl';
continue;
}
if (runDirection === false) {
ltrRunCount += 1;
if (!firstExplicitDirection) firstExplicitDirection = 'ltr';
}
}

if (rtlRunCount === 0 && ltrRunCount === 0) return undefined;
if (rtlRunCount > ltrRunCount) return 'rtl';
if (ltrRunCount > rtlRunCount) return 'ltr';
return firstExplicitDirection;
};

export const resolveEffectiveParagraphDirection = (
para: PMNode,
resolvedParagraphProperties: ParagraphProperties,
sectionDirection?: ParagraphDirection,
): ParagraphDirection | undefined => {
if (resolvedParagraphProperties.rightToLeft === true) return 'rtl';
if (resolvedParagraphProperties.rightToLeft === false) return 'ltr';
if (sectionDirection) return sectionDirection;
return inferDirectionFromRuns(para);
};

/**
* Convert indent from twips to pixels.
*/
Expand Down Expand Up @@ -273,7 +311,12 @@ export const computeParagraphAttrs = (
);
}

const isRtl = resolvedParagraphProperties.rightToLeft === true;
const normalizedDirection = resolveEffectiveParagraphDirection(
para,
resolvedParagraphProperties,
converterContext?.sectionDirection,
);
const isRtl = normalizedDirection === 'rtl';

const normalizedSpacing = normalizeParagraphSpacing(
resolvedParagraphProperties.spacing,
Expand All @@ -287,12 +330,6 @@ export const computeParagraphAttrs = (
const paragraphDecimalSeparator = DEFAULT_DECIMAL_SEPARATOR;
const tabIntervalTwips = DEFAULT_TAB_INTERVAL_TWIPS;
const normalizedFramePr = normalizeFramePr(resolvedParagraphProperties.framePr);
const normalizedDirection =
resolvedParagraphProperties.rightToLeft === true
? 'rtl'
: resolvedParagraphProperties.rightToLeft === false
? 'ltr'
: undefined;
const floatAlignment = normalizedFramePr?.xAlign;
const normalizedNumberingProperties = normalizeNumberingProperties(resolvedParagraphProperties.numberingProperties);
const dropCapDescriptor = normalizeDropCap(resolvedParagraphProperties.framePr, para, converterContext);
Expand Down Expand Up @@ -322,7 +359,7 @@ export const computeParagraphAttrs = (
keepLines: resolvedParagraphProperties.keepLines,
floatAlignment: floatAlignment,
pageBreakBefore: resolvedParagraphProperties.pageBreakBefore,
...(normalizedDirection ? { direction: normalizedDirection as 'rtl' | 'ltr', rtl: isRtl } : {}),
...(normalizedDirection ? { direction: normalizedDirection } : {}),
};

if (normalizedNumberingProperties && normalizedListRendering) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type TableStyleParagraphProps = {
};

export type ConverterContext = {
sectionDirection?: 'ltr' | 'rtl';
docx?: Record<string, unknown>;
translatedNumbering: NumberingProperties;
translatedLinkedStyles: StylesDocumentProperties;
Expand Down
Loading
Loading