diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 8bae88389b..6cc1389451 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -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). */ diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index dab52bf576..ef19d4e37f 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -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(':')}`); @@ -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'); diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 6eb70e6818..f35980447f 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -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; diff --git a/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts b/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts index b42b84dc3a..87a10e2b5a 100644 --- a/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/layout-bridge/src/paragraph-hash-utils.ts @@ -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(':'); }; diff --git a/packages/layout-engine/layout-bridge/src/position-hit.ts b/packages/layout-engine/layout-bridge/src/position-hit.ts index 601b4c1071..d70eb88622 100644 --- a/packages/layout-engine/layout-bridge/src/position-hit.ts +++ b/packages/layout-engine/layout-bridge/src/position-hit.ts @@ -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; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 3b1a4667a9..d18bfc086b 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -1727,6 +1727,95 @@ describe('resolveLayout', () => { expect(content.lines[0].isListFirstLine).toBe(true); }); + it('preserves increasing first-line marker anchor for nested RTL list levels', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'rtl-l0', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + markerWidth: 36, + markerTextWidth: 10, + }, + { + kind: 'para', + blockId: 'rtl-l1', + fromLine: 0, + toLine: 1, + x: 72, + y: 130, + width: 468, + markerWidth: 36, + markerTextWidth: 10, + }, + { + kind: 'para', + blockId: 'rtl-l2', + fromLine: 0, + toLine: 1, + x: 72, + y: 160, + width: 468, + markerWidth: 36, + markerTextWidth: 10, + }, + ], + }, + ], + }; + + const makeRtlBlock = (id: string, right: number, markerText: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ kind: 'text', text: 'RTL list item' }], + attrs: { + direction: 'rtl', + indent: { right, hanging: -24 }, + wordLayout: { + marker: { + markerText, + justification: 'right', + suffix: 'tab', + run: { fontFamily: 'Arial', fontSize: 12 }, + }, + }, + }, + }); + + const blocks: FlowBlock[] = [ + makeRtlBlock('rtl-l0', 24, '1.'), + makeRtlBlock('rtl-l1', 48, 'a.'), + makeRtlBlock('rtl-l2', 72, 'i.'), + ]; + const measures: Measure[] = [ + { kind: 'paragraph', lines: [makeLine()], totalHeight: 20 }, + { kind: 'paragraph', lines: [makeLine()], totalHeight: 20 }, + { kind: 'paragraph', lines: [makeLine()], totalHeight: 20 }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const pageItems = result.pages[0].items as any[]; + + const m0 = pageItems[0].content.marker; + const m1 = pageItems[1].content.marker; + const m2 = pageItems[2].content.marker; + + expect(m0.firstLinePaddingLeftPx).toBeLessThan(m1.firstLinePaddingLeftPx); + expect(m1.firstLinePaddingLeftPx).toBeLessThan(m2.firstLinePaddingLeftPx); + expect(m1.firstLinePaddingLeftPx - m0.firstLinePaddingLeftPx).toBe(24); + expect(m2.firstLinePaddingLeftPx - m1.firstLinePaddingLeftPx).toBe(24); + expect(m0.markerStartPx).toBeLessThan(m1.markerStartPx); + expect(m1.markerStartPx).toBeLessThan(m2.markerStartPx); + }); + it('omits marker on continuation fragment', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, diff --git a/packages/layout-engine/layout-resolved/src/resolveParagraph.ts b/packages/layout-engine/layout-resolved/src/resolveParagraph.ts index f001154597..e86e145e51 100644 --- a/packages/layout-engine/layout-resolved/src/resolveParagraph.ts +++ b/packages/layout-engine/layout-resolved/src/resolveParagraph.ts @@ -15,6 +15,7 @@ import { resolveListMarkerGeometry, resolveListTextStartPx, computeTabWidth, + resolveMarkerIndent, type MinimalMarker, type MinimalWordLayout, } from '@superdoc/common/list-marker-utils'; @@ -92,6 +93,12 @@ export function resolveParagraphContent( const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; const paraIndentLeft = paraIndent?.left ?? 0; const paraIndentRight = paraIndent?.right ?? 0; + const isRtl = (block.attrs as ParagraphAttrs | undefined)?.direction === 'rtl'; + const { + anchorIndentPx: paraMarkerAnchorIndent, + firstLinePx: markerFirstLine, + hangingPx: markerHanging, + } = resolveMarkerIndent(paraIndent, isRtl); const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); @@ -108,9 +115,9 @@ export function resolveParagraphContent( const listFirstLineTextStartPx = hasMarker ? resolverListTextStartPx( wordLayout, - paraIndentLeft, - paraIndent?.hanging ?? 0, - paraIndent?.firstLine ?? 0, + paraMarkerAnchorIndent, + markerHanging, + markerFirstLine, fragment.markerTextWidth, ) : undefined; @@ -127,9 +134,9 @@ export function resolveParagraphContent( const listFirstLineMarkerGeometry = shouldUseSharedInlinePrefixGeometry ? resolverListMarkerGeometry( wordLayout, - paraIndentLeft, - paraIndent?.hanging ?? 0, - paraIndent?.firstLine ?? 0, + paraMarkerAnchorIndent, + markerHanging, + markerFirstLine, fragment.markerTextWidth, ) : undefined; @@ -139,7 +146,7 @@ export function resolveParagraphContent( let markerStartPos = 0; if (hasMarker) { const markerTextWidth = fragment.markerTextWidth!; - const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); + const anchorPoint = paraMarkerAnchorIndent - markerHanging + markerFirstLine; const markerJustification = wordLayout!.marker!.justification ?? 'left'; let currentPos: number; if (markerJustification === 'left') { @@ -161,9 +168,9 @@ export function resolveParagraphContent( currentPos, markerJustification, wordLayout!.tabsPx, - paraIndent?.hanging, - paraIndent?.firstLine, - paraIndentLeft, + markerHanging, + markerFirstLine, + paraMarkerAnchorIndent, ); } else if (suffix === 'space') { listTabWidth = 4; @@ -175,7 +182,7 @@ export function resolveParagraphContent( if (hasMarker) { const m = wordLayout!.marker!; const justification = (m.justification ?? 'left') as 'left' | 'right' | 'center'; - const firstLinePaddingLeftPx = paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + const firstLinePaddingLeftPx = paraMarkerAnchorIndent - markerHanging + markerFirstLine; let centerPaddingAdjustPx: number | undefined; if (justification === 'center') { diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index dfac27e5c8..dffd2eb19e 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -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(':') : ''; @@ -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)); } 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 index ba5b2d014c..5e1126a93f 100644 --- 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 @@ -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. @@ -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; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index ac1074cebb..3812347a37 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -239,6 +239,48 @@ const createResolvedTestLine = (textLength: number, overrides: Partial = { ...overrides, }); +const withFallbackFragment = ( + item: ResolvedLayout['pages'][number]['items'][number], +): ResolvedLayout['pages'][number]['items'][number] => { + if (item.kind !== 'fragment' || item.fragment) { + return item; + } + + const fromLine = 'fromLine' in item && typeof item.fromLine === 'number' ? item.fromLine : 0; + const toLine = 'toLine' in item && typeof item.toLine === 'number' ? item.toLine : fromLine + 1; + + if (item.fragmentKind === 'list-item') { + return { + ...item, + fragment: { + kind: 'list-item', + blockId: item.blockId, + itemId: item.itemId, + markerText: item.markerText ?? '', + markerWidth: item.markerWidth ?? 0, + fromLine, + toLine, + x: item.x, + y: item.y, + width: item.width, + }, + }; + } + + return { + ...item, + fragment: { + kind: 'para', + blockId: item.blockId, + fromLine, + toLine, + x: item.x, + y: item.y, + width: item.width, + }, + }; +}; + const createSinglePageResolvedLayout = (item: ResolvedLayout['pages'][number]['items'][number]): ResolvedLayout => ({ version: 1, flowMode: 'paginated', @@ -250,7 +292,7 @@ const createSinglePageResolvedLayout = (item: ResolvedLayout['pages'][number]['i number: 1, width: 400, height: 500, - items: [item], + items: [withFallbackFragment(item)], }, ], }); @@ -4726,7 +4768,6 @@ describe('DomPainter', () => { attrs: { alignment: 'center', direction: 'rtl', - rtl: true, }, }; const footerMeasure: Measure = { @@ -5785,6 +5826,92 @@ describe('DomPainter', () => { expect(paragraphMark.style.left).toBe('232px'); }); + it('renders RTL resolved list first-line anchor on padding-right for nested numbered levels', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'resolved-rtl-marker', + runs: [{ text: 'RTL nested item', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 16 }], + attrs: { direction: 'rtl' as const }, + }; + + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [createResolvedTestLine(16)], + totalHeight: 20, + }; + + const paragraphLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'resolved-rtl-marker', fromLine: 0, toLine: 1, x: 30, y: 40, width: 300 }, + ], + }, + ], + }; + + const resolvedLayout = createSinglePageResolvedLayout({ + kind: 'fragment', + id: 'para:resolved-rtl-marker:0:1', + pageIndex: 0, + x: 30, + y: 40, + width: 300, + height: 20, + fragmentKind: 'para', + blockId: 'resolved-rtl-marker', + fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, + content: { + lines: [ + { + line: createResolvedTestLine(16), + lineIndex: 0, + availableWidth: 300, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: true, + hasExplicitSegmentPositioning: false, + indentOffset: 0, + }, + ], + marker: { + text: 'i.', + justification: 'right', + suffix: 'tab', + markerStartPx: 72, + suffixWidthPx: 24, + firstLinePaddingLeftPx: 72, + run: { + fontFamily: 'Arial', + fontSize: 12, + }, + }, + }, + }); + + const painter = createTestPainter({ + blocks: [paragraphBlock], + measures: [paragraphMeasure], + }); + + painter.setResolvedLayout(resolvedLayout); + painter.paint(paragraphLayout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement; + + expect(lineEl.getAttribute('dir')).toBe('rtl'); + expect(lineEl.style.paddingRight).toBe('72px'); + expect(lineEl.style.paddingLeft).toBe(''); + expect(markerEl.textContent).toBe('i.'); + }); + it('renders a resolved drop cap without a legacy descriptor on the block', () => { const paragraphBlock: FlowBlock = { kind: 'paragraph', @@ -8168,7 +8295,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 = { @@ -8223,7 +8350,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 = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 43c2cab6c4..ff044f2d37 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -107,7 +107,10 @@ import { import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { applyImageClipPath } from './utils/image-clip-path.js'; -import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; +import { + isMinimalWordLayout as isMinimalWordLayoutShared, + resolveMarkerIndent, +} from '@superdoc/common/list-marker-utils'; import { computeTabWidth, resolvePainterListMarkerGeometry, @@ -3169,7 +3172,7 @@ export class DomPainter { fragment.markerTextWidth, resolvedLine.indentOffset, ); - + const isRtl = block.attrs?.direction === 'rtl'; const lineEl = this.renderLine( block, resolvedLine.line, @@ -3207,7 +3210,11 @@ export class DomPainter { // Render marker on list first line if (resolvedLine.isListFirstLine && resolvedMarker) { - lineEl.style.paddingLeft = `${resolvedMarker.firstLinePaddingLeftPx}px`; + if (isRtl) { + lineEl.style.paddingRight = `${resolvedMarker.firstLinePaddingLeftPx}px`; + } else { + lineEl.style.paddingLeft = `${resolvedMarker.firstLinePaddingLeftPx}px`; + } if (!resolvedMarker.vanish) { const markerContainer = this.doc!.createElement('span'); @@ -3226,12 +3233,24 @@ export class DomPainter { markerContainer.style.position = 'relative'; if (resolvedMarker.justification === 'right') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${resolvedMarker.markerStartPx}px`; + if (isRtl) { + markerContainer.style.right = `${resolvedMarker.markerStartPx}px`; + } else { + markerContainer.style.left = `${resolvedMarker.markerStartPx}px`; + } } else if (resolvedMarker.justification === 'center') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${resolvedMarker.markerStartPx - (resolvedMarker.centerPaddingAdjustPx ?? 0)}px`; - lineEl.style.paddingLeft = - parseFloat(lineEl.style.paddingLeft) + (resolvedMarker.centerPaddingAdjustPx ?? 0) + 'px'; + if (isRtl) { + markerContainer.style.right = `${resolvedMarker.markerStartPx - (resolvedMarker.centerPaddingAdjustPx ?? 0)}px`; + lineEl.style.paddingRight = + ( + parseFloat(lineEl.style.paddingRight || '0') + (resolvedMarker.centerPaddingAdjustPx ?? 0) + ).toString() + 'px'; + } else { + markerContainer.style.left = `${resolvedMarker.markerStartPx - (resolvedMarker.centerPaddingAdjustPx ?? 0)}px`; + lineEl.style.paddingLeft = + parseFloat(lineEl.style.paddingLeft) + (resolvedMarker.centerPaddingAdjustPx ?? 0) + 'px'; + } } markerEl.style.fontFamily = @@ -3279,6 +3298,12 @@ export class DomPainter { const paraIndent = block.attrs?.indent; const paraIndentLeft = paraIndent?.left ?? 0; const paraIndentRight = paraIndent?.right ?? 0; + const isRtl = block.attrs?.direction === 'rtl'; + const { + anchorIndentPx: paraMarkerAnchorIndent, + firstLinePx: markerFirstLine, + hangingPx: markerHanging, + } = resolveMarkerIndent(paraIndent, isRtl); const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); @@ -3290,9 +3315,9 @@ export class DomPainter { !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker ? resolvePainterListTextStartPx({ wordLayout, - indentLeftPx: paraIndentLeft, - hangingIndentPx: paraIndent?.hanging ?? 0, - firstLineIndentPx: paraIndent?.firstLine ?? 0, + indentLeftPx: paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, markerTextWidthPx: fragment.markerTextWidth, }) : undefined; @@ -3308,9 +3333,9 @@ export class DomPainter { const listFirstLineMarkerGeometry = shouldUseSharedInlinePrefixGeometry ? resolvePainterListMarkerGeometry({ wordLayout, - indentLeftPx: paraIndentLeft, - hangingIndentPx: paraIndent?.hanging ?? 0, - firstLineIndentPx: paraIndent?.firstLine ?? 0, + indentLeftPx: paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, markerTextWidthPx: fragment.markerTextWidth, }) : undefined; @@ -3319,7 +3344,7 @@ export class DomPainter { let markerStartPos = 0; if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) { const markerTextWidth = fragment.markerTextWidth!; - const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); + const anchorPoint = paraMarkerAnchorIndent - markerHanging + markerFirstLine; const markerJustification = wordLayout.marker.justification ?? 'left'; let currentPos: number; if (markerJustification === 'left') { @@ -3341,9 +3366,9 @@ export class DomPainter { currentPos, markerJustification, wordLayout.tabsPx, - paraIndent?.hanging, - paraIndent?.firstLine, - paraIndentLeft, + markerHanging, + markerFirstLine, + paraMarkerAnchorIndent, ); } else if (suffix === 'space') { listTabWidth = 4; @@ -3427,7 +3452,12 @@ export class DomPainter { if (!marker) { return; } - lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; + const firstLineIndent = paraMarkerAnchorIndent - markerHanging + markerFirstLine; + if (isRtl) { + lineEl.style.paddingRight = `${firstLineIndent}px`; + } else { + lineEl.style.paddingLeft = `${firstLineIndent}px`; + } if (!marker.run.vanish) { const markerContainer = this.doc!.createElement('span'); @@ -3448,11 +3478,22 @@ export class DomPainter { markerContainer.style.position = 'relative'; if (markerJustification === 'right') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos}px`; + if (isRtl) { + markerContainer.style.right = `${markerStartPos}px`; + } else { + markerContainer.style.left = `${markerStartPos}px`; + } } else if (markerJustification === 'center') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; - lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; + if (isRtl) { + markerContainer.style.right = `${markerStartPos - fragment.markerTextWidth! / 2}px`; + lineEl.style.paddingRight = + (parseFloat(lineEl.style.paddingRight || '0') + fragment.markerTextWidth! / 2).toString() + 'px'; + } else { + markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; + lineEl.style.paddingLeft = + parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; + } } markerEl.style.fontFamily = toCssFontFamily(marker.run.fontFamily) ?? marker.run.fontFamily; @@ -7596,7 +7637,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: @@ -7740,7 +7781,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(':') : ''; @@ -7927,7 +7967,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)); } @@ -8153,28 +8192,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) { diff --git a/packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts b/packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts index 8076dc9bc8..893cccb6ea 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts @@ -1,474 +1,32 @@ -/** - * Tests for BiDi (Bidirectional Text) Utilities - * - * Covers: - * - mirrorIndentForRtl: Swapping left/right and inverting firstLine/hanging - * - ensureBidiIndentPx: Clamping tiny values and adding synthetic defaults - */ - import { describe, it, expect } from 'vitest'; -import { mirrorIndentForRtl, ensureBidiIndentPx, DEFAULT_BIDI_INDENT_PX } from './bidi.js'; +import { mirrorIndentForRtl } from './bidi.js'; import type { ParagraphIndent } from '@superdoc/contracts'; describe('mirrorIndentForRtl', () => { - describe('basic mirroring', () => { - it('should swap left and right indents', () => { - const input: ParagraphIndent = { left: 10, right: 20 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ left: 20, right: 10 }); - }); - - it('should invert positive firstLine', () => { - const input: ParagraphIndent = { firstLine: 15 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ firstLine: -15 }); - }); - - it('should invert negative firstLine', () => { - const input: ParagraphIndent = { firstLine: -15 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ firstLine: 15 }); - }); - - it('should invert positive hanging', () => { - const input: ParagraphIndent = { hanging: 10 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ hanging: -10 }); - }); - - it('should invert negative hanging', () => { - const input: ParagraphIndent = { hanging: -10 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ hanging: 10 }); - }); - }); - - describe('complete mirroring', () => { - it('should mirror all fields when all present', () => { - const input: ParagraphIndent = { - left: 10, - right: 20, - firstLine: 5, - hanging: 8, - }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ - left: 20, - right: 10, - firstLine: -5, - hanging: -8, - }); - }); - - it('should handle mixed positive and negative values', () => { - const input: ParagraphIndent = { - left: -10, - right: 20, - firstLine: -5, - hanging: 8, - }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ - left: 20, - right: -10, - firstLine: 5, - hanging: -8, - }); - }); - }); - - describe('edge cases', () => { - it('should return same object for empty indent', () => { - const input: ParagraphIndent = {}; - const result = mirrorIndentForRtl(input); - expect(result).toBe(input); // Should be same reference - }); - - it('should mirror zero values correctly', () => { - const input: ParagraphIndent = { left: 0, right: 0, firstLine: 0, hanging: 0 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ left: 0, right: 0, firstLine: -0, hanging: -0 }); - }); - - it('should handle only left indent', () => { - const input: ParagraphIndent = { left: 15 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ right: 15 }); - }); - - it('should handle only right indent', () => { - const input: ParagraphIndent = { right: 15 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ left: 15 }); - }); - - it('should handle only firstLine', () => { - const input: ParagraphIndent = { firstLine: 12 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ firstLine: -12 }); - }); - - it('should handle only hanging', () => { - const input: ParagraphIndent = { hanging: 8 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ hanging: -8 }); - }); - - it('should handle very large values', () => { - const input: ParagraphIndent = { left: 10000, right: 20000 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ left: 20000, right: 10000 }); - }); - - it('should handle fractional values', () => { - const input: ParagraphIndent = { left: 10.5, right: 20.75, firstLine: 5.25 }; - const result = mirrorIndentForRtl(input); - expect(result).toEqual({ left: 20.75, right: 10.5, firstLine: -5.25 }); - }); - }); - - describe('immutability', () => { - it('should not mutate input object', () => { - const input: ParagraphIndent = { left: 10, right: 20, firstLine: 5 }; - const original = { ...input }; - mirrorIndentForRtl(input); - expect(input).toEqual(original); - }); - - it('should return new object when mutations occur', () => { - const input: ParagraphIndent = { left: 10 }; - const result = mirrorIndentForRtl(input); - expect(result).not.toBe(input); - }); - }); -}); - -describe('ensureBidiIndentPx', () => { - const MIN_BIDI_CLAMP_INDENT_PX = 1; - - describe('clamping tiny values', () => { - it('should clamp tiny positive left value to MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { left: 0.5 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp tiny negative left value to -MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { left: -0.5 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(-MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp tiny positive right value to MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { right: 0.3 }; - const result = ensureBidiIndentPx(input); - expect(result.right).toBe(MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp tiny negative right value to -MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { right: -0.8 }; - const result = ensureBidiIndentPx(input); - expect(result.right).toBe(-MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp both left and right when both are tiny', () => { - const input: ParagraphIndent = { left: 0.2, right: -0.7 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(MIN_BIDI_CLAMP_INDENT_PX); - expect(result.right).toBe(-MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp value close to zero (0.001)', () => { - const input: ParagraphIndent = { left: 0.001 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should clamp value close to zero (0.999)', () => { - const input: ParagraphIndent = { left: 0.999 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(MIN_BIDI_CLAMP_INDENT_PX); - expect(result.__bidiFallback).toBe('clamped'); - }); - }); - - describe('no clamping for normal values', () => { - it('should not clamp zero value', () => { - const input: ParagraphIndent = { left: 0 }; - const result = ensureBidiIndentPx(input); - // Zero has no horizontal indent, so synthetic defaults apply - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should not clamp value equal to MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { left: 1 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(1); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should not clamp values greater than MIN_BIDI_CLAMP_INDENT_PX', () => { - const input: ParagraphIndent = { left: 10, right: 20 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(10); - expect(result.right).toBe(20); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should not clamp large negative value', () => { - const input: ParagraphIndent = { left: -50 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(-50); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should not clamp very large value', () => { - const input: ParagraphIndent = { left: 1000 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(1000); - expect(result.__bidiFallback).toBeUndefined(); - }); + it('swaps left and right', () => { + const input: ParagraphIndent = { left: 10, right: 20 }; + expect(mirrorIndentForRtl(input)).toEqual({ left: 20, right: 10 }); }); - describe('synthetic defaults', () => { - it('should add synthetic defaults when no horizontal indent', () => { - const input: ParagraphIndent = {}; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should add synthetic defaults when both left and right are zero', () => { - const input: ParagraphIndent = { left: 0, right: 0 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should add synthetic defaults when only left is zero', () => { - const input: ParagraphIndent = { left: 0 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should add synthetic defaults when only right is zero', () => { - const input: ParagraphIndent = { right: 0 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should not add synthetic defaults when left is non-zero', () => { - const input: ParagraphIndent = { left: 10 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(10); - expect(result.right).toBeUndefined(); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should not add synthetic defaults when right is non-zero', () => { - const input: ParagraphIndent = { right: 15 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBeUndefined(); - expect(result.right).toBe(15); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should preserve firstLine and hanging when adding synthetic defaults', () => { - const input: ParagraphIndent = { firstLine: 10, hanging: 5 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.firstLine).toBe(10); - expect(result.hanging).toBe(5); - expect(result.__bidiFallback).toBe('synthetic'); - }); + it('inverts firstLine and hanging', () => { + const input: ParagraphIndent = { firstLine: 12, hanging: -8 }; + expect(mirrorIndentForRtl(input)).toEqual({ firstLine: -12, hanging: 8 }); }); - describe('__bidiFallback flag', () => { - it('should set __bidiFallback to "clamped" when clamping occurs', () => { - const input: ParagraphIndent = { left: 0.5 }; - const result = ensureBidiIndentPx(input); - expect(result.__bidiFallback).toBe('clamped'); - }); - - it('should set __bidiFallback to "synthetic" when adding defaults', () => { - const input: ParagraphIndent = {}; - const result = ensureBidiIndentPx(input); - expect(result.__bidiFallback).toBe('synthetic'); - }); - - it('should not set __bidiFallback when no adjustments needed', () => { - const input: ParagraphIndent = { left: 10, right: 20 }; - const result = ensureBidiIndentPx(input); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should prioritize "clamped" over "synthetic" when both would apply', () => { - // This tests when clamping results in zero (edge case) - // Actually, clamping will result in 1 or -1, not zero, so this won't trigger synthetic - const input: ParagraphIndent = { left: 0.5, right: 0 }; - const result = ensureBidiIndentPx(input); - // left clamped to 1, right is 0 → no synthetic because left is non-zero - expect(result.__bidiFallback).toBe('clamped'); - }); + it('handles combined indent values', () => { + const input: ParagraphIndent = { left: 24, right: 48, firstLine: 6, hanging: 0 }; + expect(mirrorIndentForRtl(input)).toEqual({ left: 48, right: 24, firstLine: -6, hanging: -0 }); }); - describe('undefined and null handling', () => { - it('should preserve undefined left value', () => { - const input: ParagraphIndent = { right: 10 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBeUndefined(); - expect(result.right).toBe(10); - }); - - it('should preserve undefined right value', () => { - const input: ParagraphIndent = { left: 10 }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(10); - expect(result.right).toBeUndefined(); - }); - - it('should not clamp undefined values', () => { - const input: ParagraphIndent = { firstLine: 5, hanging: 3 }; - const result = ensureBidiIndentPx(input); - // No horizontal indent → synthetic defaults - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.right).toBe(DEFAULT_BIDI_INDENT_PX); - expect(result.firstLine).toBe(5); - expect(result.hanging).toBe(3); - }); + it('returns same object when no mirrorable fields exist', () => { + const input: ParagraphIndent = {}; + expect(mirrorIndentForRtl(input)).toBe(input); }); - describe('edge cases - Math.sign behavior', () => { - it('should handle Math.sign(0) correctly (returns 0, fallback to 1)', () => { - // The clamping logic is: (Math.sign(value) || 1) * MIN_BIDI_CLAMP_INDENT_PX - // For positive zero: Math.sign(0) = 0 → (0 || 1) = 1 - // But zero doesn't trigger clamping (abs > 0 && abs < 1 is false) - const input: ParagraphIndent = { left: 0 }; - const result = ensureBidiIndentPx(input); - // Zero doesn't get clamped, but triggers synthetic defaults - expect(result.left).toBe(DEFAULT_BIDI_INDENT_PX); - }); - - it('should handle positive tiny value correctly', () => { - const input: ParagraphIndent = { left: 0.5 }; - const result = ensureBidiIndentPx(input); - // Math.sign(0.5) = 1 → (1 || 1) * 1 = 1 - expect(result.left).toBe(1); - }); - - it('should handle negative tiny value correctly', () => { - const input: ParagraphIndent = { left: -0.5 }; - const result = ensureBidiIndentPx(input); - // Math.sign(-0.5) = -1 → (-1 || 1) * 1 = -1 - expect(result.left).toBe(-1); - }); - }); - - describe('immutability', () => { - it('should not mutate input object', () => { - const input: ParagraphIndent = { left: 10, right: 20 }; - const original = { ...input }; - ensureBidiIndentPx(input); - expect(input).toEqual(original); - }); - - it('should return new object with spread operator', () => { - const input: ParagraphIndent = { left: 10 }; - const result = ensureBidiIndentPx(input); - expect(result).not.toBe(input); - }); - - it('should preserve extra properties (though none expected)', () => { - const input = { left: 10, right: 20, extra: 'value' } as never; - const result = ensureBidiIndentPx(input); - expect(result.extra).toBe('value'); - }); - }); - - describe('integration tests', () => { - it('should work correctly with mirrorIndentForRtl', () => { - const input: ParagraphIndent = { left: 0.5, right: 20 }; - const ensured = ensureBidiIndentPx(input); - const mirrored = mirrorIndentForRtl(ensured); - - expect(ensured.left).toBe(1); // Clamped - expect(ensured.right).toBe(20); - expect(mirrored.left).toBe(20); - expect(mirrored.right).toBe(1); - }); - - it('should handle complete BiDi workflow', () => { - // 1. Start with tiny values - const input: ParagraphIndent = { left: 0.3, right: -0.6, firstLine: 5 }; - - // 2. Ensure minimum indents - const ensured = ensureBidiIndentPx(input); - expect(ensured.left).toBe(1); - expect(ensured.right).toBe(-1); - expect(ensured.firstLine).toBe(5); - expect(ensured.__bidiFallback).toBe('clamped'); - - // 3. Mirror for RTL - const mirrored = mirrorIndentForRtl(ensured); - expect(mirrored.left).toBe(-1); - expect(mirrored.right).toBe(1); - expect(mirrored.firstLine).toBe(-5); - }); - - it('should handle empty → synthetic → mirror workflow', () => { - const input: ParagraphIndent = {}; - - const ensured = ensureBidiIndentPx(input); - expect(ensured.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(ensured.right).toBe(DEFAULT_BIDI_INDENT_PX); - - const mirrored = mirrorIndentForRtl(ensured); - expect(mirrored.left).toBe(DEFAULT_BIDI_INDENT_PX); - expect(mirrored.right).toBe(DEFAULT_BIDI_INDENT_PX); - }); - }); - - describe('special numeric values', () => { - it('should handle NaN gracefully (NaN != null is true)', () => { - const input: ParagraphIndent = { left: NaN }; - const result = ensureBidiIndentPx(input); - // NaN behavior: Math.abs(NaN) = NaN, NaN > 0 is false, NaN < 1 is false - // So clamping condition (abs > 0 && abs < 1) is false - // Then hasHorizontalIndent: typeof NaN === 'number' is true, but NaN !== 0 is true - // So hasHorizontalIndent is true → no synthetic defaults - expect(typeof result.left).toBe('number'); - expect(result.left).toBe(NaN); - }); - - it('should handle Infinity', () => { - const input: ParagraphIndent = { left: Infinity }; - const result = ensureBidiIndentPx(input); - // Math.abs(Infinity) = Infinity, Infinity > 0 is true, Infinity < 1 is false - // So no clamping occurs - expect(result.left).toBe(Infinity); - expect(result.__bidiFallback).toBeUndefined(); - }); - - it('should handle -Infinity', () => { - const input: ParagraphIndent = { left: -Infinity }; - const result = ensureBidiIndentPx(input); - expect(result.left).toBe(-Infinity); - expect(result.__bidiFallback).toBeUndefined(); - }); + it('does not mutate input', () => { + const input: ParagraphIndent = { left: 10, right: 20, firstLine: 5 }; + const original = { ...input }; + mirrorIndentForRtl(input); + expect(input).toEqual(original); }); }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/bidi.ts b/packages/layout-engine/pm-adapter/src/attributes/bidi.ts index 15724a6b60..50bd56a015 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/bidi.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/bidi.ts @@ -6,9 +6,6 @@ import type { ParagraphIndent } from '@superdoc/contracts'; -const MIN_BIDI_CLAMP_INDENT_PX = 1; -export const DEFAULT_BIDI_INDENT_PX = 24; - /** * Mirror paragraph indent for RTL text. * Swaps left/right indents and inverts firstLine/hanging. @@ -36,36 +33,3 @@ export const mirrorIndentForRtl = (indent: ParagraphIndent): ParagraphIndent => return mutated ? mirrored : indent; }; - -/** - * Ensure BiDi paragraphs have minimum horizontal indent for proper rendering. - * Clamps very small indents and adds synthetic defaults if needed. - */ -export const ensureBidiIndentPx = (indent: ParagraphIndent): ParagraphIndent & { __bidiFallback?: string } => { - const adjusted: ParagraphIndent & { __bidiFallback?: string } = { ...indent }; - - const clamp = (value: number | undefined): number | undefined => { - if (value == null) return value; - const abs = Math.abs(value); - if (abs > 0 && abs < MIN_BIDI_CLAMP_INDENT_PX) { - adjusted.__bidiFallback = adjusted.__bidiFallback ?? 'clamped'; - return (Math.sign(value) || 1) * MIN_BIDI_CLAMP_INDENT_PX; - } - return value; - }; - - adjusted.left = clamp(adjusted.left); - adjusted.right = clamp(adjusted.right); - - const hasHorizontalIndent = - (typeof adjusted.left === 'number' && adjusted.left !== 0) || - (typeof adjusted.right === 'number' && adjusted.right !== 0); - - if (!hasHorizontalIndent) { - adjusted.left = DEFAULT_BIDI_INDENT_PX; - adjusted.right = DEFAULT_BIDI_INDENT_PX; - adjusted.__bidiFallback = 'synthetic'; - } - - return adjusted; -}; diff --git a/packages/layout-engine/pm-adapter/src/attributes/index.ts b/packages/layout-engine/pm-adapter/src/attributes/index.ts index e3bd9c4644..1cce8a79a5 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/index.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/index.ts @@ -26,7 +26,7 @@ export { normalizeAlignment, normalizeParagraphSpacing, normalizeLineRule } from export { normalizeOoxmlTabs, normalizeTabVal, normalizeTabLeader } from './tabs.js'; // BiDi text -export { mirrorIndentForRtl, ensureBidiIndentPx, DEFAULT_BIDI_INDENT_PX } from './bidi.js'; +export { mirrorIndentForRtl } from './bidi.js'; // Paragraph attributes export { computeParagraphAttrs, deepClone } from './paragraph.js'; diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index 6cb18cd83e..ec0384f593 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -15,6 +15,7 @@ import { normalizeFramePr, normalizeDropCap, computeParagraphAttrs, + resolveEffectiveParagraphDirection, computeRunAttrs, hasExplicitParagraphRunProperties, } from './paragraph.js'; @@ -159,6 +160,58 @@ describe('computeParagraphAttrs', () => { expect(paragraphAttrs.tabs?.[0]).toEqual({ val: 'start', pos: 720 }); }); + it('maps logical indent start/end to physical left/right for LTR paragraphs', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + indent: { start: 720, end: 1440 }, + }, + }, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); + + expect(paragraphAttrs.indent?.left).toBe(twipsToPx(720)); + expect(paragraphAttrs.indent?.right).toBe(twipsToPx(1440)); + }); + + it('maps logical indent start/end for RTL paragraphs and applies mirroring', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + indent: { start: 720, end: 1440 }, + }, + }, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); + + expect(paragraphAttrs.indent?.left).toBe(twipsToPx(1440)); + expect(paragraphAttrs.indent?.right).toBe(twipsToPx(720)); + }); + + it('mirrors physical indent values for RTL paragraphs', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + indent: { left: 720, right: 1440, firstLine: 360, hanging: 240 }, + }, + }, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); + + expect(paragraphAttrs.indent?.left).toBe(twipsToPx(1440)); + expect(paragraphAttrs.indent?.right).toBe(twipsToPx(720)); + expect(paragraphAttrs.indent?.firstLine).toBe(-twipsToPx(360)); + expect(paragraphAttrs.indent?.hanging).toBe(-twipsToPx(240)); + }); + it('exposes resolved paragraph properties when no converter context is provided', () => { const paragraph: PMNode = { type: { name: 'paragraph' }, @@ -273,7 +326,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(); }); }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index f9fb1f79ea..4e3a7aa5a4 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -23,6 +23,7 @@ import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js'; import { normalizeAlignment, normalizeParagraphSpacing } from './spacing-indent.js'; import { normalizeOoxmlTabs } from './tabs.js'; import { normalizeParagraphBorders, normalizeParagraphShading } from './borders.js'; +import { mirrorIndentForRtl } from './bidi.js'; import type { ConverterContext } from '../converter-context.js'; import { @@ -37,6 +38,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; @@ -62,6 +64,43 @@ export const deepClone = (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. */ @@ -86,6 +125,29 @@ const normalizeIndentTwipsToPx = (indent?: ParagraphIndent | null): ParagraphInd return Object.keys(result).length > 0 ? result : undefined; }; +const resolveLogicalIndentToPhysical = ( + indent: ParagraphIndent | undefined, + _direction: ParagraphDirection | undefined, +): ParagraphIndent | undefined => { + if (!indent) return undefined; + + const resolved: ParagraphIndent = { ...indent }; + const source = indent as ParagraphIndent & { start?: unknown; end?: unknown }; + + if (source.start != null) { + resolved.left = source.start as number; + } + + if (source.end != null) { + resolved.right = source.end as number; + } + + delete (resolved as ParagraphIndent & { start?: unknown }).start; + delete (resolved as ParagraphIndent & { end?: unknown }).end; + + return resolved; +}; + export const normalizeFramePr = (value: ParagraphFrameProperties | undefined): ParagraphFrame | undefined => { if (!value) return undefined; @@ -273,13 +335,24 @@ export const computeParagraphAttrs = ( ); } - const isRtl = resolvedParagraphProperties.rightToLeft === true; + const normalizedDirection = resolveEffectiveParagraphDirection( + para, + resolvedParagraphProperties, + converterContext?.sectionDirection, + ); + const isRtl = normalizedDirection === 'rtl'; const normalizedSpacing = normalizeParagraphSpacing( resolvedParagraphProperties.spacing, Boolean(resolvedParagraphProperties.numberingProperties), ); - const normalizedIndent = normalizeIndentTwipsToPx(resolvedParagraphProperties.indent as ParagraphIndent); + const indentWithPhysicalSides = resolveLogicalIndentToPhysical( + resolvedParagraphProperties.indent as ParagraphIndent, + normalizedDirection, + ); + const normalizedIndentBase = normalizeIndentTwipsToPx(indentWithPhysicalSides); + const normalizedIndent = + isRtl && normalizedIndentBase ? mirrorIndentForRtl(normalizedIndentBase) : normalizedIndentBase; const normalizedTabStops = normalizeOoxmlTabs(resolvedParagraphProperties.tabStops); const normalizedAlignment = normalizeAlignment(resolvedParagraphProperties.justification, isRtl); const normalizedBorders = normalizeParagraphBorders(resolvedParagraphProperties.borders); @@ -287,12 +360,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); @@ -322,7 +389,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) { diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 89e75b5e76..bf83cc21f2 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -20,6 +20,7 @@ export type TableStyleParagraphProps = { }; export type ConverterContext = { + sectionDirection?: 'ltr' | 'rtl'; docx?: Record; translatedNumbering: NumberingProperties; translatedLinkedStyles: StylesDocumentProperties; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index cbcb6ca314..68045b9c54 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3004,6 +3004,61 @@ describe('paragraph converters', () => { expect(context.blocks).toHaveLength(1); expect(getMarkerText(context.blocks[0])).toBe('二.'); }); + + it('updates converterContext.sectionDirection when crossing to next section', () => { + const trackedChanges: TrackedChangesConfig = { + mode: 'review', + enabled: true, + }; + const context = createParagraphHandlerContext(trackedChanges); + context.converterContext.sectionDirection = 'rtl'; + context.sectionState = { + ranges: [ + { + sectionIndex: 0, + startParagraphIndex: 0, + endParagraphIndex: 0, + sectPr: null, + margins: null, + pageSize: null, + orientation: null, + columns: null, + type: 'nextPage', + titlePg: false, + }, + { + sectionIndex: 1, + startParagraphIndex: 0, + endParagraphIndex: 1, + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:bidi', attributes: { 'w:val': '0' } }], + }, + margins: null, + pageSize: null, + orientation: null, + columns: null, + type: 'nextPage', + titlePg: false, + }, + ] as any, + currentSectionIndex: 0, + currentParagraphIndex: 0, + }; + + handleParagraphNode( + { + type: 'paragraph', + attrs: { paragraphProperties: {} }, + content: [{ type: 'text', text: 'section switch paragraph' }], + } as PMNode, + context, + ); + + expect(context.sectionState.currentSectionIndex).toBe(1); + expect(context.converterContext.sectionDirection).toBe('ltr'); + }); }); describe('Run merging', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 0028f147d1..e1a60e113b 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -76,6 +76,17 @@ import { import { chartNodeToDrawingBlock } from './chart.js'; import { tableNodeToBlock } from './table.js'; +function resolveSectionDirectionFromSectPr(sectPr: unknown): 'ltr' | 'rtl' | undefined { + if (!sectPr || typeof sectPr !== 'object') return undefined; + const elements = (sectPr as { elements?: Array<{ name?: string; attributes?: Record }> }).elements; + if (!Array.isArray(elements)) return undefined; + const bidi = elements.find((element) => element?.name === 'w:bidi'); + if (!bidi) return undefined; + const val = bidi.attributes?.['w:val'] ?? bidi.attributes?.val; + if (val === '0' || val === 0 || val === false || val === 'false' || val === 'off') return 'ltr'; + return 'rtl'; +} + function sourceAnchorFromNode(node: PMNode): SourceAnchor | undefined { const sourceAnchor = (node.attrs as Record | undefined)?.sourceAnchor; return sourceAnchor && typeof sourceAnchor === 'object' && !Array.isArray(sourceAnchor) @@ -1081,6 +1092,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): blocks.push(sectionBreak); recordBlockKind?.(sectionBreak.kind); sectionState!.currentSectionIndex++; + converterContext.sectionDirection = resolveSectionDirectionFromSectPr(nextSection.sectPr); } } diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index ba5363b936..9b980bb5be 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3189,9 +3189,8 @@ describe('toFlowBlocks', () => { const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); 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); + expect(paragraph.attrs?.indent?.left).toBe(12); + expect(paragraph.attrs?.indent?.right).toBe(24); }); it('does not mark paragraphs as RTL when w:bidi is explicitly false', () => { @@ -3216,7 +3215,62 @@ describe('toFlowBlocks', () => { const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); expect(paragraph.attrs?.direction).toBe('ltr'); - expect(paragraph.attrs?.rtl).toBe(false); + }); + + it('inherits paragraph direction from body sectPr w:bidi when paragraph direction is missing', () => { + const pmDoc = { + type: 'doc', + attrs: { + bodySectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:bidi', attributes: {} }], + }, + }, + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: {}, + }, + content: [{ type: 'text', text: 'Section inherited RTL' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc); + expect(blocks).toHaveLength(1); + const paragraph = blocks[0]; + expect(paragraph.kind).toBe('paragraph'); + expect(paragraph.attrs?.direction).toBe('rtl'); + }); + + it('does not inherit RTL direction when body sectPr w:bidi is explicitly false', () => { + const pmDoc = { + type: 'doc', + attrs: { + bodySectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:bidi', attributes: { 'w:val': '0' } }], + }, + }, + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: {}, + }, + content: [{ type: 'text', text: 'Section inherited LTR' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc); + expect(blocks).toHaveLength(1); + const paragraph = blocks[0]; + expect(paragraph.kind).toBe('paragraph'); + expect(paragraph.attrs?.direction).toBe('ltr'); }); it('handles multiple page breaks', () => { @@ -4562,7 +4616,6 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); expect(blocks[0].attrs?.direction).toBe('rtl'); - expect(blocks[0].attrs?.rtl).toBe(true); expect(blocks[0].attrs?.alignment).toBeUndefined(); }); @@ -4592,7 +4645,6 @@ describe('toFlowBlocks', () => { 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', }); @@ -4625,7 +4677,6 @@ describe('toFlowBlocks', () => { 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', }); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index b7dde6420c..6eb6f092eb 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -65,6 +65,17 @@ import type { const DEFAULT_FONT = 'Times New Roman'; const DEFAULT_SIZE = 10 / 0.75; // 10pt in pixels +function resolveSectionDirectionFromSectPr(sectPr: unknown): 'ltr' | 'rtl' | undefined { + if (!sectPr || typeof sectPr !== 'object') return undefined; + const elements = (sectPr as { elements?: Array<{ name?: string; attributes?: Record }> }).elements; + if (!Array.isArray(elements)) return undefined; + const bidi = elements.find((element) => element?.name === 'w:bidi'); + if (!bidi) return undefined; + const val = bidi.attributes?.['w:val'] ?? bidi.attributes?.val; + if (val === '0' || val === 0 || val === false || val === 'false' || val === 'off') return 'ltr'; + return 'rtl'; +} + /** * Dispatch map for node type handlers. * Maps node type names to their corresponding handler functions. @@ -181,6 +192,8 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): // Range-aware section analysis (matches toFlowBlocks semantics) const bodySectionProps = doc.attrs?.bodySectPr ?? doc.attrs?.sectPr; + converterContext.sectionDirection = + converterContext.sectionDirection ?? resolveSectionDirectionFromSectPr(bodySectionProps); const sectionRanges = options?.emitSectionBreaks ? analyzeSectionRanges(doc, bodySectionProps) : []; publishSectionMetadata(sectionRanges, options); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts index 33532c6009..548af084d3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts @@ -196,7 +196,9 @@ export function computeCaretLayoutRectGeometry( const availableWidth = Math.max(0, fragment.width - (indentAdjust + indent.right)); const charX = measureCharacterX(block, line, pmOffset, availableWidth); - const localX = fragment.x + indentAdjust + charX; + const isRtlParagraph = block.attrs?.direction === 'rtl'; + const resolvedCharX = isRtlParagraph ? Math.max(0, availableWidth - charX) : charX; + const localX = fragment.x + indentAdjust + resolvedCharX; const lineOffset = lineHeightBeforeIndex(measure.lines, fragment.fromLine, index); const localY = fragment.y + lineOffset; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts index 4dc4ded44c..262682ed04 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts @@ -434,5 +434,144 @@ describe('CaretGeometry', () => { expect(result).not.toBe(null); expect(result?.pageIndex).toBe(0); }); + + it('places caret at right edge for RTL paragraph start boundary', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'rtl-para', + runs: [{ text: 'אבגדה', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 6 }], + attrs: { direction: 'rtl' }, + }; + const line: Line = { + fromRun: 0, + toRun: 0, + fromChar: 0, + toChar: 5, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 16, + }; + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('rtl-para', 10, 10, 200, 16, 0, 1, 1, 6); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: null, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const result = computeCaretLayoutRectGeometry(deps, 1, false); + expect(result).not.toBe(null); + expect(result?.x).toBeCloseTo(210, 3); + }); + + it('keeps caret on right side for RTL empty paragraph boundary fallback', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'rtl-empty', + runs: [{ text: '', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 1 }], + attrs: { direction: 'rtl' }, + }; + const line: Line = { + fromRun: 0, + toRun: 0, + fromChar: 0, + toChar: 0, + width: 0, + ascent: 12, + descent: 4, + lineHeight: 16, + }; + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('rtl-empty', 10, 10, 200, 16, 0, 1, 1, 1); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: null, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const result = computeCaretLayoutRectGeometry(deps, 1, false); + expect(result).not.toBe(null); + expect(result?.x).toBeCloseTo(210, 3); + }); + + it('places caret at visual left for RTL line end boundary', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'rtl-line-end', + runs: [{ text: 'אבגדה', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 6 }], + attrs: { direction: 'rtl' }, + }; + const line: Line = { + fromRun: 0, + toRun: 0, + fromChar: 0, + toChar: 5, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 16, + }; + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('rtl-line-end', 10, 10, 200, 16, 0, 1, 1, 6); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: null, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const result = computeCaretLayoutRectGeometry(deps, 6, false); + expect(result).not.toBe(null); + expect(result?.x).toBeCloseTo(110, 3); + }); + + it('computes decreasing X across mid-line positions for RTL paragraphs without DOM fallback', () => { + const block: FlowBlock = { + ...createMockParagraphBlock('rtl-midline', 1, 12), + attrs: { direction: 'rtl' }, + }; + const line = createMockLine(1, 12, 16); + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('rtl-midline', 10, 10, 200, 16, 0, 1, 1, 12); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: null, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const start = computeCaretLayoutRectGeometry(deps, 1, false); + const middle = computeCaretLayoutRectGeometry(deps, 6, false); + const end = computeCaretLayoutRectGeometry(deps, 11, false); + + expect(start).not.toBeNull(); + expect(middle).not.toBeNull(); + expect(end).not.toBeNull(); + expect(start!.x).toBeGreaterThan(middle!.x); + expect(middle!.x).toBeGreaterThan(end!.x); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index 231a8b279a..3f02be0225 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -83,10 +83,20 @@ export const VerticalNavigation = Extension.create({ handleKeyDown(view, event) { // Guard clauses if (view.composing || !editor.isEditable) return false; - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; } + if (event.key === 'Home' || event.key === 'End') { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + if (!isPresenting(editor)) return false; + const targetPos = resolveLineBoundaryPosition(editor, view.state.selection, event.key); + if (!Number.isFinite(targetPos)) return false; + const selection = buildSelection(view.state, targetPos, event.shiftKey); + if (!selection) return false; + view.dispatch(view.state.tr.setSelection(selection)); + return true; + } if (event.key === 'PageUp' || event.key === 'PageDown') { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; @@ -234,6 +244,29 @@ function getCurrentCoords(editor, selection) { }; } +/** + * Resolves the PM boundary position for Home/End within the current visual line. + * + * @param {Object} editor + * @param {import('prosemirror-state').Selection} selection + * @param {'Home'|'End'} key + * @returns {number | null} + */ +function resolveLineBoundaryPosition(editor, selection, key) { + const coords = getCurrentCoords(editor, selection); + if (!coords) return null; + const doc = editor.presentationEditor?.visibleHost?.ownerDocument ?? document; + const caretX = coords.clientX; + const caretY = coords.clientY + coords.height / 2; + const lineEl = findLineElementAtPoint(doc, caretX, caretY); + if (!lineEl) return null; + + const pmStart = Number(lineEl.dataset?.pmStart); + const pmEnd = Number(lineEl.dataset?.pmEnd); + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) return null; + return key === 'Home' ? pmStart : pmEnd; +} + /** * Finds the adjacent line center Y in client space and associated page index. * Also returns the PM position range from the line's data attributes so that diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js index 31c14c69d6..4bf37b6925 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js @@ -151,6 +151,64 @@ afterEach(() => { }); describe('VerticalNavigation', () => { + it('handles Home within current visual line using line pmStart', () => { + const { line1 } = createDomStructure(); + line1.dataset.pmStart = '10'; + line1.dataset.pmEnd = '20'; + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view } = createEnvironment(); + const handled = plugin.props.handleKeyDown(view, { key: 'Home', shiftKey: false }); + + expect(handled).toBe(true); + expect(view.state.selection.from).toBe(10); + expect(view.state.selection.to).toBe(10); + }); + + it('handles End with Shift within current visual line using line pmEnd', () => { + const { line1 } = createDomStructure(); + line1.dataset.pmStart = '10'; + line1.dataset.pmEnd = '20'; + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view } = createEnvironment(); + const handled = plugin.props.handleKeyDown(view, { key: 'End', shiftKey: true }); + + expect(handled).toBe(true); + expect(view.state.selection.from).toBe(1); + expect(view.state.selection.to).toBe(20); + }); + + it('maps Home to pmStart for RTL visual line', () => { + const { line1 } = createDomStructure(); + line1.dataset.pmStart = '30'; + line1.dataset.pmEnd = '40'; + line1.setAttribute('dir', 'rtl'); + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view } = createEnvironment(); + const handled = plugin.props.handleKeyDown(view, { key: 'Home', shiftKey: false }); + + expect(handled).toBe(true); + expect(view.state.selection.from).toBe(30); + expect(view.state.selection.to).toBe(30); + }); + + it('maps End to pmEnd for RTL visual line', () => { + const { line1 } = createDomStructure(); + line1.dataset.pmStart = '30'; + line1.dataset.pmEnd = '40'; + line1.setAttribute('dir', 'rtl'); + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view } = createEnvironment(); + const handled = plugin.props.handleKeyDown(view, { key: 'End', shiftKey: false }); + + expect(handled).toBe(true); + expect(view.state.selection.from).toBe(40); + expect(view.state.selection.to).toBe(40); + }); + it('returns false when editor is not presenting', () => { const { plugin, view } = createEnvironment({ presenting: false }); diff --git a/shared/common/list-marker-utils.ts b/shared/common/list-marker-utils.ts index 7fb061e524..dd5c8e1ecd 100644 --- a/shared/common/list-marker-utils.ts +++ b/shared/common/list-marker-utils.ts @@ -427,6 +427,25 @@ export function isMinimalWordLayout(value: unknown): value is MinimalWordLayout * Used for marker modes whose rendering contract differs from the shared geometry * helper, such as right/center-justified markers and firstLineIndentMode paragraphs. */ +export type MarkerIndent = { + anchorIndentPx: number; + firstLinePx: number; + hangingPx: number; +}; + +export function resolveMarkerIndent( + indent: { left?: number; right?: number; firstLine?: number; hanging?: number } | undefined, + isRtl: boolean, +): MarkerIndent { + const left = indent?.left ?? 0; + const right = indent?.right ?? 0; + return { + anchorIndentPx: isRtl ? right : left, + firstLinePx: isRtl ? -(indent?.firstLine ?? 0) : (indent?.firstLine ?? 0), + hangingPx: isRtl ? -(indent?.hanging ?? 0) : (indent?.hanging ?? 0), + }; +} + export function computeTabWidth( currentPos: number, justification: string, diff --git a/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts index 3f6bd073fa..874f7fe858 100644 --- a/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts +++ b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts @@ -4,6 +4,7 @@ import { activateNote, expectActiveStoryTextToContain, getBodyStoryText, + moveActiveStoryCursorToEnd, waitForActiveStory, } from '../../helpers/story-surfaces.js'; @@ -36,9 +37,11 @@ test('double-click rendered endnote to edit it through the presentation surface' noteId: '1', }); - await superdoc.page.keyboard.press('End'); + // Stabilize caret in the active note editor before typing. + await moveActiveStoryCursorToEnd(superdoc.page); await superdoc.page.keyboard.insertText(' edited'); await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'simple endnote edited'); await expect(endnote).toContainText('This is a simple endnote edited'); await superdoc.page.keyboard.press('Backspace'); diff --git a/tests/behavior/tests/lists/rtl-list-nesting.spec.ts b/tests/behavior/tests/lists/rtl-list-nesting.spec.ts new file mode 100644 index 0000000000..80270c5810 --- /dev/null +++ b/tests/behavior/tests/lists/rtl-list-nesting.spec.ts @@ -0,0 +1,170 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { listItems } from '../../helpers/document-api.js'; +import { getAllListLevels } from '../../helpers/lists.js'; + +const MARKER_SELECTOR = '.superdoc-paragraph-marker'; + +async function setAllListItemsDirectionRtl(superdoc: { + page: import('@playwright/test').Page; + waitForStable: () => Promise; +}) { + const snapshot = await listItems(superdoc.page); + + for (const item of snapshot.items) { + await superdoc.page.evaluate((target) => { + const paragraphApi = (window as any).editor?.doc?.format?.paragraph; + if (!paragraphApi?.setDirection) { + throw new Error('Document API is unavailable: expected editor.doc.format.paragraph.setDirection().'); + } + + paragraphApi.setDirection({ + target, + direction: 'rtl', + }); + }, item.address); + } + + await superdoc.waitForStable(); +} + +async function getLinePmRange( + superdoc: { page: import('@playwright/test').Page }, + lineIndex: number, +): Promise<{ start: number; end: number }> { + return superdoc.page.evaluate((idx) => { + const line = document.querySelectorAll('.superdoc-line')[idx]; + if (!line) throw new Error(`Line ${idx} not found.`); + const start = Number(line.getAttribute('data-pm-start')); + const end = Number(line.getAttribute('data-pm-end')); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + throw new Error(`Line ${idx} has invalid PM range.`); + } + return { start, end }; + }, lineIndex); +} + +test.describe('rtl lists', () => { + test('keeps nested levels and renders markers on the rtl side', async ({ superdoc }) => { + await superdoc.type('1. level 0'); + await superdoc.newLine(); + await superdoc.press('Tab'); + await superdoc.type('level 1'); + await superdoc.newLine(); + await superdoc.press('Tab'); + await superdoc.type('level 2'); + await superdoc.waitForStable(); + + await setAllListItemsDirectionRtl(superdoc); + + const levels = await getAllListLevels(superdoc); + expect(levels).toEqual([0, 1, 2]); + + const markerCount = await superdoc.page.locator(MARKER_SELECTOR).count(); + expect(markerCount).toBe(3); + + const lineDirs = await superdoc.page.evaluate(() => + Array.from(document.querySelectorAll('.superdoc-line')) + .slice(0, 3) + .map((el) => el.getAttribute('dir')), + ); + expect(lineDirs).toEqual(['rtl', 'rtl', 'rtl']); + + const markerOnRightForEachLine = await superdoc.page.evaluate(() => { + const markers = Array.from(document.querySelectorAll('.superdoc-paragraph-marker')).slice(0, 3); + return markers.map((marker) => { + const line = marker.closest('.superdoc-line') as HTMLElement | null; + if (!line) return false; + const markerRect = marker.getBoundingClientRect(); + const lineRect = line.getBoundingClientRect(); + return markerRect.left > lineRect.left + lineRect.width * 0.5; + }); + }); + expect(markerOnRightForEachLine).toEqual([true, true, true]); + }); + + test('applies Tab and Shift+Tab level changes in rtl lists', async ({ superdoc }) => { + await superdoc.type('1. one'); + await superdoc.newLine(); + await superdoc.type('two'); + await superdoc.newLine(); + await superdoc.type('three'); + await superdoc.waitForStable(); + + await setAllListItemsDirectionRtl(superdoc); + + const initialLevels = await getAllListLevels(superdoc); + expect(initialLevels).toEqual([0, 0, 0]); + + await superdoc.page.keyboard.press('ArrowUp'); + await superdoc.waitForStable(); + await superdoc.press('Tab'); + await superdoc.waitForStable(); + + const afterTabLevels = await getAllListLevels(superdoc); + expect(afterTabLevels).toEqual([0, 1, 0]); + + await superdoc.press('Shift+Tab'); + await superdoc.waitForStable(); + + const afterShiftTabLevels = await getAllListLevels(superdoc); + expect(afterShiftTabLevels).toEqual([0, 0, 0]); + }); + + test('ArrowLeft and ArrowRight move caret visually in rtl list line', async ({ superdoc }) => { + await superdoc.type('1. one'); + await superdoc.newLine(); + await superdoc.type('two two two'); + await superdoc.waitForStable(); + + await setAllListItemsDirectionRtl(superdoc); + + const range = await getLinePmRange(superdoc, 1); + const interiorPos = range.start + Math.max(1, Math.floor((range.end - range.start) / 2)); + await superdoc.setTextSelection(interiorPos, interiorPos); + await superdoc.waitForStable(); + + const beforeLeft = await superdoc.getSelection(); + + await superdoc.press('ArrowLeft'); + await superdoc.waitForStable(); + + const afterLeft = await superdoc.getSelection(); + expect(afterLeft.from).not.toBe(beforeLeft.from); + + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + + const afterRight = await superdoc.getSelection(); + expect(afterRight.from).not.toBe(afterLeft.from); + expect(afterRight.from).toBe(beforeLeft.from); + }); + + test('Shift+ArrowLeft and Shift+ArrowRight expand selection in rtl list line', async ({ superdoc }) => { + await superdoc.type('1. one'); + await superdoc.newLine(); + await superdoc.type('two two two'); + await superdoc.waitForStable(); + + await setAllListItemsDirectionRtl(superdoc); + + const range = await getLinePmRange(superdoc, 1); + const interiorPos = range.start + Math.max(1, Math.floor((range.end - range.start) / 2)); + await superdoc.setTextSelection(interiorPos, interiorPos); + await superdoc.waitForStable(); + + await superdoc.press('Shift+ArrowLeft'); + await superdoc.waitForStable(); + + const selectionAfterShiftLeft = await superdoc.getSelection(); + expect(Math.abs(selectionAfterShiftLeft.to - selectionAfterShiftLeft.from)).toBeGreaterThan(0); + + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + + await superdoc.press('Shift+ArrowRight'); + await superdoc.waitForStable(); + + const selectionAfterShiftRight = await superdoc.getSelection(); + expect(Math.abs(selectionAfterShiftRight.to - selectionAfterShiftRight.from)).toBeGreaterThan(0); + }); +}); diff --git a/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts b/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts index c52c0f4108..164e12995a 100644 --- a/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts +++ b/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts @@ -115,4 +115,184 @@ test.describe('RTL arrow key cursor movement (SD-2390)', () => { expect(xAfter).toBeGreaterThan(xBefore); expect(after.from).toBeGreaterThan(before.from); }); + + test('Shift+ArrowLeft expands selection visually left in RTL paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + await superdoc.page.mouse.click(box.x + box.width - 20, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + + await superdoc.page.keyboard.down('Shift'); + await superdoc.page.keyboard.press('ArrowLeft'); + await superdoc.page.keyboard.up('Shift'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + expect(after.to - after.from).toBeGreaterThan(0); + }); + + test('Shift+ArrowRight expands selection visually right in RTL paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + await superdoc.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + + await superdoc.page.keyboard.down('Shift'); + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.page.keyboard.up('Shift'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + expect(after.to - after.from).toBeGreaterThan(0); + }); + + test('Home moves caret to visual start (right edge) of RTL line', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + // Click near line middle to avoid already being at boundary. + await superdoc.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + const xBefore = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, before.from); + + await superdoc.page.keyboard.press('Home'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + const xAfter = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, after.from); + + expect(after.from).not.toBe(before.from); + expect(xAfter).toBeGreaterThan(xBefore); + }); + + test('End moves caret to visual end (left edge) of RTL line', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + // Click near line middle first, then force known start state via Home. + await superdoc.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await superdoc.waitForStable(); + + await superdoc.page.keyboard.press('Home'); + await superdoc.waitForStable(); + + const beforeHome = await superdoc.getSelection(); + const xBeforeHome = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, beforeHome.from); + + await superdoc.page.keyboard.press('End'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + const xAfter = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, after.from); + + expect(xAfter).toBeLessThanOrEqual(xBeforeHome); + + await superdoc.page.keyboard.press('End'); + await superdoc.waitForStable(); + + const afterSecondEnd = await superdoc.getSelection(); + const xAfterSecondEnd = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, afterSecondEnd.from); + + // Boundary behavior can differ by 1 PM position across engines; keep a visual invariant: + // repeated End should not move caret to the visual right. + expect(xAfterSecondEnd).toBeLessThanOrEqual(xAfter + 0.5); + }); + + test('Mod+A selects full document with RTL content', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const firstLine = superdoc.page.locator('.superdoc-line').first(); + const firstLineBox = await firstLine.boundingBox(); + if (!firstLineBox) throw new Error('First line not visible'); + await superdoc.page.mouse.click(firstLineBox.x + 20, firstLineBox.y + firstLineBox.height / 2); + await superdoc.waitForStable(); + + const selectAllShortcut = process.platform === 'darwin' ? 'Meta+A' : 'Control+A'; + await superdoc.page.keyboard.press(selectAllShortcut); + await superdoc.waitForStable(); + + const stateSelection = await superdoc.page.evaluate(() => { + const state = (window as any).superdoc?.activeEditor?.state; + if (!state?.selection) return null; + return { + from: state.selection.from, + to: state.selection.to, + min: 0, + max: state.doc.content.size, + }; + }); + + expect(stateSelection).not.toBeNull(); + expect( + (stateSelection as { to: number; from: number }).to - (stateSelection as { to: number; from: number }).from, + ).toBeGreaterThan(0); + expect((stateSelection as { min: number; from: number }).from).toBe( + (stateSelection as { min: number; from: number }).min, + ); + expect((stateSelection as { max: number; to: number }).to).toBe( + (stateSelection as { max: number; to: number }).max, + ); + }); + + test('Typing in a new empty paragraph created from RTL line keeps RTL direction', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const rtlEnd = Number(await rtlLine.getAttribute('data-pm-end')); + if (!Number.isFinite(rtlEnd)) throw new Error('RTL line end is not available'); + + await superdoc.setTextSelection(rtlEnd, rtlEnd); + await superdoc.page.keyboard.press('Enter'); + await superdoc.waitForStable(); + + await superdoc.page.keyboard.insertText('ا'); + await superdoc.waitForStable(); + + const insertedLine = superdoc.page.locator('.superdoc-line').nth(1); + await expect(insertedLine).toContainText('ا'); + + const direction = await insertedLine.evaluate((el) => getComputedStyle(el).direction); + expect(direction).toBe('rtl'); + }); });