From 589a26b2d7d359d4e01ce8b96ce8408d1536bfdd Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Tue, 19 May 2026 14:20:03 -0700 Subject: [PATCH 1/2] Skip justify-self: flex-end on RTL table inside RTL container An RTL currently always renders with `justify-self: flex-end` to push it to the right edge of an LTR parent. When the parent context is already RTL (e.g. an RTL table cell, an RTL
/
, or an RTL list item) the table is already aligned correctly by RTL flow, so the extra `justify-self` causes visual misalignment. Propagate the current block's direction into context.implicitFormat from handleTable (cell children), handleFormatContainer, and handleListItem. The direction format handler now skips `justify-self: flex-end` when context.implicitFormat.direction is `rtl`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../block/directionFormatHandler.ts | 8 +- .../handlers/handleFormatContainer.ts | 30 ++- .../lib/modelToDom/handlers/handleListItem.ts | 8 +- .../lib/modelToDom/handlers/handleTable.ts | 8 +- .../test/endToEndTest.ts | 245 +++++++++++++++++- .../block/directionFormatHandlerTest.ts | 25 ++ .../handlers/handleFormatContainerTest.ts | 57 ++++ .../modelToDom/handlers/handleListItemTest.ts | 34 +++ .../modelToDom/handlers/handleTableTest.ts | 66 +++++ 9 files changed, 469 insertions(+), 12 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts index fdcecdd2bfec..55603d3ae997 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts @@ -13,12 +13,16 @@ export const directionFormatHandler: FormatHandler = { format.direction = dir == 'rtl' ? 'rtl' : 'ltr'; } }, - apply: (format, element) => { + apply: (format, element, context) => { if (format.direction) { element.style.direction = format.direction; } - if (format.direction == 'rtl' && isElementOfType(element, 'table')) { + if ( + format.direction == 'rtl' && + isElementOfType(element, 'table') && + context.implicitFormat.direction != 'rtl' + ) { element.style.justifySelf = 'flex-end'; } }, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index da761c33f25d..d6895af24458 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -53,13 +53,29 @@ export const handleFormatContainer: ContentModelBlockHandler { - context.modelHandlers.blockGroupChildren(doc, containerNode, container, context); - }); - } else { - context.modelHandlers.blockGroupChildren(doc, containerNode, container, context); - } + stackFormat( + context, + container.format.direction ? { direction: container.format.direction } : null, + () => { + if (container.tagName == 'pre') { + stackFormat(context, PreChildFormat, () => { + context.modelHandlers.blockGroupChildren( + doc, + containerNode, + container, + context + ); + }); + } else { + context.modelHandlers.blockGroupChildren( + doc, + containerNode, + container, + context + ); + } + } + ); element = containerNode; } diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts index 1d6d51e42f43..16e4901d7212 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts @@ -76,7 +76,13 @@ export const handleListItem: ContentModelBlockHandler = ( applyFormat(li, context.formatAppliers.listItemElement, listItem.format, context); stackFormat(context, listItem.formatHolder.format, () => { - context.modelHandlers.blockGroupChildren(doc, li, listItem, context); + stackFormat( + context, + listItem.format.direction ? { direction: listItem.format.direction } : null, + () => { + context.modelHandlers.blockGroupChildren(doc, li, listItem, context); + } + ); }); } else { // There is no level for this list item, that means it should be moved out of the list diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index 2f183cf580b2..57f5c24f434c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -150,7 +150,13 @@ export const handleTable: ContentModelBlockHandler = ( applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); } - context.modelHandlers.blockGroupChildren(doc, td, cell, context); + stackFormat( + context, + cell.format.direction ? { direction: cell.format.direction } : null, + () => { + context.modelHandlers.blockGroupChildren(doc, td, cell, context); + } + ); }); context.onNodeCreated?.(cell, td); diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index 8d55177d6929..81c3b25b36c4 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2,7 +2,7 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; import { contentModelToText, createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; -import { expectHtml } from './testUtils'; +import { expectHtml, itChromeOnly } from './testUtils'; import { ContentModelBlockFormat, ContentModelDocument, @@ -567,6 +567,249 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { ); }); + it('LTR table under RTL table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'ltr', + }, + isImplicit: true, + }, + ], + format: { + direction: 'ltr', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'ltr', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'rtl' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + + it('RTL table under LTR table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'rtl', + }, + isImplicit: true, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'ltr', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'ltr' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + + it('RTL table under RTL table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'rtl', + }, + isImplicit: true, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'rtl' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + it('Table under styled block', () => { runTest( 'aa
bb
cc
', diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts index 23f1e9d03af0..ab1b96b19c95 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts @@ -90,4 +90,29 @@ describe('directionFormatHandler.apply', () => { '
' ); }); + + it('RTL on table, parent implicit direction is LTR, applies justify-self', () => { + const table = document.createElement('table'); + format.direction = 'rtl'; + context.implicitFormat.direction = 'ltr'; + directionFormatHandler.apply(format, table, context); + expect(table.outerHTML).toBe( + '
' + ); + }); + + it('RTL on table, parent implicit direction is RTL, skips justify-self', () => { + const table = document.createElement('table'); + format.direction = 'rtl'; + context.implicitFormat.direction = 'rtl'; + directionFormatHandler.apply(format, table, context); + expect(table.outerHTML).toBe('
'); + }); + + it('RTL on non-table element, never applies justify-self', () => { + const td = document.createElement('td'); + format.direction = 'rtl'; + directionFormatHandler.apply(format, td, context); + expect(td.outerHTML).toBe(''); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index d03a267af130..48ce9128e70e 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -110,6 +110,63 @@ describe('handleFormatContainer', () => { }); }); + it('RTL container propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('div', { direction: 'rtl' }); + const paragraph = createParagraph(); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('Container without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('div'); + const paragraph = createParagraph(); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + }); + + it('Pre container with RTL direction propagates both pre format and direction', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('pre', { direction: 'rtl' }); + const paragraph = createParagraph(); + paragraph.segments.push(createText('test')); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + let capturedWhiteSpace: string | undefined; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + capturedWhiteSpace = ctx.implicitFormat.whiteSpace; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + expect(capturedWhiteSpace).toBe('pre'); + expect(context.implicitFormat.direction).toBeUndefined(); + expect(context.implicitFormat.whiteSpace).toBeUndefined(); + }); + it('With onNodeCreated', () => { const parent = document.createElement('div'); const quote = createFormatContainer('blockquote'); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts index 1d31b5764bf2..324e6b915c07 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts @@ -436,6 +436,40 @@ describe('handleListItem without format handler', () => { '
  1. test1
    test2
    test3
    test4
' ); }); + + it('List item with RTL direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const listItem = createListItem([createListLevel('OL')]); + listItem.format.direction = 'rtl'; + listItem.blocks.push(createParagraph()); + + let capturedDirection: string | undefined; + handleBlockGroupChildrenSpy.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleListItem(document, parent, listItem, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('List item without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const listItem = createListItem([createListLevel('OL')]); + listItem.blocks.push(createParagraph()); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + handleBlockGroupChildrenSpy.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleListItem(document, parent, listItem, context, null); + + expect(capturedDirection).toBe('rtl'); + }); }); describe('handleListItem with cache', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index dd01a58e61f7..bb89ee995695 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -660,6 +660,72 @@ describe('handleTable', () => { ); }); + it('Cell with RTL direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(false, false, false, { direction: 'rtl' }); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('Cell with LTR direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(false, false, false, { direction: 'ltr' }); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('ltr'); + // implicitFormat must be restored + expect(context.implicitFormat.direction).toBe('rtl'); + }); + + it('Cell without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('rtl'); + }); + it('handleTable without cache', () => { const parent = document.createElement('div'); const tableModel = createTable(1); From 35a96290ba6a247b44740677a5a49ef5c68a37fc Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Tue, 19 May 2026 14:25:35 -0700 Subject: [PATCH 2/2] fix build --- packages/roosterjs-content-model-dom/test/endToEndTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index 81c3b25b36c4..91a11f330c2b 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2,7 +2,7 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; import { contentModelToText, createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; -import { expectHtml, itChromeOnly } from './testUtils'; +import { expectHtml } from './testUtils'; import { ContentModelBlockFormat, ContentModelDocument,