Skip to content

Commit 4649b31

Browse files
author
Artem Nistuley
committed
feat: rtl support, phase 2
1 parent c1ad3f6 commit 4649b31

22 files changed

Lines changed: 722 additions & 61 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1467,7 +1467,6 @@ export type ParagraphAttrs = {
14671467
/** Marks an empty paragraph that only exists to carry section properties. */
14681468
sectPrMarker?: boolean;
14691469
direction?: 'ltr' | 'rtl';
1470-
rtl?: boolean;
14711470
isTocEntry?: boolean;
14721471
tocInstruction?: string;
14731472
/** Floating alignment for positioned paragraphs (from w:framePr/@w:xAlign). */

packages/layout-engine/layout-bridge/src/cache.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,8 @@ const hashRuns = (block: FlowBlock): string => {
391391
if (sh.color) parts.push(`shc:${sh.color}`);
392392
}
393393

394-
// Direction and RTL
394+
// Direction
395395
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
396-
if (attrs.rtl) parts.push('rtl');
397396

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

550-
// Direction and RTL
549+
// Direction
551550
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
552-
if (attrs.rtl) parts.push('rtl');
553551

554552
// Pagination properties
555553
if (attrs.keepNext) parts.push('kn');

packages/layout-engine/layout-bridge/src/diff.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,6 @@ const paragraphAttrsEqual = (a?: ParagraphAttrs, b?: ParagraphAttrs): boolean =>
370370
a.keepNext !== b.keepNext ||
371371
a.keepLines !== b.keepLines ||
372372
a.direction !== b.direction ||
373-
a.rtl !== b.rtl ||
374373
a.floatAlignment !== b.floatAlignment
375374
) {
376375
return false;

packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,8 @@ export const hashParagraphAttrs = (attrs: ParagraphAttrs | undefined): string =>
201201
if (sh.color) parts.push(`shc:${sh.color}`);
202202
}
203203

204-
// Direction and RTL
204+
// Direction
205205
if (attrs.direction) parts.push(`dir:${attrs.direction}`);
206-
if (attrs.rtl) parts.push('rtl');
207206

208207
return parts.join(':');
209208
};

packages/layout-engine/layout-bridge/src/position-hit.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,6 @@ export const isRtlBlock = (block: FlowBlock): boolean => {
126126
if (typeof directionAttr === 'string' && directionAttr.toLowerCase() === 'rtl') {
127127
return true;
128128
}
129-
if (typeof attrs.rtl === 'boolean') {
130-
return attrs.rtl;
131-
}
132129
return false;
133130
};
134131

packages/layout-engine/layout-resolved/src/versionSignature.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,6 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
292292
attrs.shading?.fill ?? '',
293293
attrs.shading?.color ?? '',
294294
attrs.direction ?? '',
295-
attrs.rtl ? '1' : '',
296295
attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
297296
].join(':')
298297
: '';
@@ -437,7 +436,6 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
437436
hash = hashString(hash, attrs.shading?.fill ?? '');
438437
hash = hashString(hash, attrs.shading?.color ?? '');
439438
hash = hashString(hash, attrs.direction ?? '');
440-
hash = hashString(hash, attrs.rtl ? '1' : '');
441439
if (attrs.borders) {
442440
hash = hashString(hash, hashParagraphBorders(attrs.borders));
443441
}

packages/layout-engine/painters/dom/src/features/rtl-paragraph/rtl-styles.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import type { ParagraphAttrs } from '@superdoc/contracts';
1111

1212
/**
1313
* Returns true when the paragraph attributes indicate right-to-left direction.
14-
* Checks both the `direction` string and the legacy `rtl` boolean flag.
1514
*/
16-
export const isRtlParagraph = (attrs: ParagraphAttrs | undefined): boolean =>
17-
attrs?.direction === 'rtl' || attrs?.rtl === true;
15+
export const isRtlParagraph = (attrs: ParagraphAttrs | undefined): boolean => attrs?.direction === 'rtl';
1816

1917
/**
2018
* Compute the effective CSS text-align for a paragraph.
@@ -45,6 +43,9 @@ export const applyRtlStyles = (element: HTMLElement, attrs: ParagraphAttrs | und
4543
if (rtl) {
4644
element.setAttribute('dir', 'rtl');
4745
element.style.direction = 'rtl';
46+
} else {
47+
element.removeAttribute('dir');
48+
element.style.direction = '';
4849
}
4950
element.style.textAlign = resolveTextAlign(attrs?.alignment, rtl);
5051
return rtl;

packages/layout-engine/painters/dom/src/index.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4727,7 +4727,6 @@ describe('DomPainter', () => {
47274727
attrs: {
47284728
alignment: 'center',
47294729
direction: 'rtl',
4730-
rtl: true,
47314730
},
47324731
};
47334732
const footerMeasure: Measure = {
@@ -8155,7 +8154,7 @@ describe('DomPainter', () => {
81558154
kind: 'paragraph',
81568155
id: 'rtl-block',
81578156
runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16 }],
8158-
attrs: { direction: 'rtl' as const, rtl: true, ...attrs },
8157+
attrs: { direction: 'rtl' as const, ...attrs },
81598158
});
81608159

81618160
const rtlMeasure: Measure = {
@@ -8210,7 +8209,7 @@ describe('DomPainter', () => {
82108209
{ kind: 'tab', width: 40, fontFamily: 'Arial', fontSize: 16 } as any,
82118210
{ text: 'عالم', fontFamily: 'Arial', fontSize: 16 },
82128211
],
8213-
attrs: { direction: 'rtl' as const, rtl: true },
8212+
attrs: { direction: 'rtl' as const },
82148213
};
82158214

82168215
const tabMeasure: Measure = {

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7447,7 +7447,7 @@ const hasListMarkerProperties = (
74477447
* - Position markers (pmStart, pmEnd)
74487448
* - Special tokens (page numbers, etc.)
74497449
* - List marker properties (numId, ilvl, markerText) - for list indent changes
7450-
* - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, rtl, tabs)
7450+
* - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, tabs)
74517451
* - Table cell content and paragraph formatting within cells
74527452
*
74537453
* For table blocks, a deep hash is computed across all rows and cells, including:
@@ -7591,7 +7591,6 @@ const deriveBlockVersion = (block: FlowBlock): string => {
75917591
attrs.shading?.fill ?? '',
75927592
attrs.shading?.color ?? '',
75937593
attrs.direction ?? '',
7594-
attrs.rtl ? '1' : '',
75957594
attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
75967595
].join(':')
75977596
: '';
@@ -7778,7 +7777,6 @@ const deriveBlockVersion = (block: FlowBlock): string => {
77787777
hash = hashString(hash, attrs.shading?.fill ?? '');
77797778
hash = hashString(hash, attrs.shading?.color ?? '');
77807779
hash = hashString(hash, attrs.direction ?? '');
7781-
hash = hashString(hash, attrs.rtl ? '1' : '');
77827780
if (attrs.borders) {
77837781
hash = hashString(hash, hashParagraphBorders(attrs.borders));
77847782
}
@@ -8004,28 +8002,6 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record<
80048002
});
80058003
};
80068004

8007-
const resolveParagraphDirection = (attrs?: ParagraphAttrs): 'ltr' | 'rtl' | undefined => {
8008-
if (attrs?.direction) {
8009-
return attrs.direction;
8010-
}
8011-
if (attrs?.rtl === true) {
8012-
return 'rtl';
8013-
}
8014-
if (attrs?.rtl === false) {
8015-
return 'ltr';
8016-
}
8017-
return undefined;
8018-
};
8019-
8020-
const applyParagraphDirection = (element: HTMLElement, attrs?: ParagraphAttrs): void => {
8021-
const direction = resolveParagraphDirection(attrs);
8022-
if (!direction) {
8023-
return;
8024-
}
8025-
element.setAttribute('dir', direction);
8026-
element.style.direction = direction;
8027-
};
8028-
80298005
const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => {
80308006
if (!attrs) return;
80318007
if (attrs.styleId) {

packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
normalizeFramePr,
1616
normalizeDropCap,
1717
computeParagraphAttrs,
18+
resolveEffectiveParagraphDirection,
1819
computeRunAttrs,
1920
hasExplicitParagraphRunProperties,
2021
} from './paragraph.js';
@@ -273,7 +274,117 @@ describe('computeParagraphAttrs', () => {
273274
const { paragraphAttrs } = computeParagraphAttrs(paragraph as never);
274275

275276
expect(paragraphAttrs.direction).toBe('rtl');
276-
expect(paragraphAttrs.rtl).toBe(true);
277+
});
278+
279+
it('uses section direction fallback when paragraph direction is not explicit', () => {
280+
const paragraph: PMNode = {
281+
type: { name: 'paragraph' },
282+
attrs: {
283+
paragraphProperties: {},
284+
},
285+
};
286+
287+
const converterContext = {
288+
sectionDirection: 'rtl',
289+
translatedNumbering: {},
290+
translatedLinkedStyles: { docDefaults: {}, styles: {} },
291+
tableInfo: null,
292+
};
293+
294+
const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
295+
expect(paragraphAttrs.direction).toBe('rtl');
296+
});
297+
});
298+
299+
describe('resolveEffectiveParagraphDirection', () => {
300+
it('prefers resolved paragraph rightToLeft over section direction', () => {
301+
const paragraph: PMNode = {
302+
type: { name: 'paragraph' },
303+
attrs: {
304+
paragraphProperties: {
305+
rightToLeft: true,
306+
},
307+
},
308+
};
309+
310+
const direction = resolveEffectiveParagraphDirection(paragraph as never, { rightToLeft: true } as never, 'ltr');
311+
expect(direction).toBe('rtl');
312+
});
313+
314+
it('uses section direction when paragraph direction is not explicit', () => {
315+
const paragraph: PMNode = {
316+
type: { name: 'paragraph' },
317+
attrs: {
318+
paragraphProperties: {},
319+
},
320+
};
321+
322+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, 'rtl');
323+
expect(direction).toBe('rtl');
324+
});
325+
326+
it('infers rtl when all runs with explicit direction are rtl', () => {
327+
const paragraph: PMNode = {
328+
type: { name: 'paragraph' },
329+
content: [
330+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
331+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
332+
],
333+
};
334+
335+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
336+
expect(direction).toBe('rtl');
337+
});
338+
339+
it('infers ltr when explicit ltr runs are the majority', () => {
340+
const paragraph: PMNode = {
341+
type: { name: 'paragraph' },
342+
content: [
343+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
344+
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
345+
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'def' }] },
346+
],
347+
};
348+
349+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
350+
expect(direction).toBe('ltr');
351+
});
352+
353+
it('infers rtl when explicit rtl runs are the majority', () => {
354+
const paragraph: PMNode = {
355+
type: { name: 'paragraph' },
356+
content: [
357+
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
358+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
359+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
360+
],
361+
};
362+
363+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
364+
expect(direction).toBe('rtl');
365+
});
366+
367+
it('uses first explicit run direction as tie-breaker for mixed runs', () => {
368+
const paragraph: PMNode = {
369+
type: { name: 'paragraph' },
370+
content: [
371+
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
372+
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
373+
],
374+
};
375+
376+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
377+
expect(direction).toBe('rtl');
378+
});
379+
380+
it('returns undefined when no direction signal exists', () => {
381+
const paragraph: PMNode = {
382+
type: { name: 'paragraph' },
383+
content: [{ type: 'run', attrs: { runProperties: {} }, content: [{ type: 'text', text: 'plain text' }] }],
384+
};
385+
386+
const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
387+
expect(direction).toBeUndefined();
277388
});
278389
});
279390

0 commit comments

Comments
 (0)