diff --git a/packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts b/packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts index d5bcf25a82..7b61154b9b 100644 --- a/packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts +++ b/packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts @@ -72,6 +72,37 @@ describe('Non-collapse rule 2: table w:bidiVisual MUST NOT make cell paragraphs const paragraphContext = resolveParagraphDirection({}, sectionContext, cellContext); expect(paragraphContext.inlineDirection).toBeUndefined(); }); + + // SD-3141: explicit `w:bidiVisual w:val="0"` is a real signal per §17.4.1 + + // §17.17.4 and can override a style-cascade `true`. The resolver must + // distinguish "no signal" (undefined) from "explicit false" (ltr), mirroring + // the paragraph resolver's handling of `w:bidi w:val="0"`. + + it('table with explicit rightToLeft:false → visualDirection is ltr', () => { + const sectionContext = resolveSectionDirection(undefined); + const tableContext = resolveTableDirection({ rightToLeft: false }, sectionContext); + expect(tableContext.visualDirection).toBe('ltr'); + }); + + it('table with explicit bidiVisual:false → visualDirection is ltr', () => { + const sectionContext = resolveSectionDirection(undefined); + const tableContext = resolveTableDirection({ bidiVisual: false }, sectionContext); + expect(tableContext.visualDirection).toBe('ltr'); + }); + + it('table with no signal → visualDirection stays undefined', () => { + const sectionContext = resolveSectionDirection(undefined); + const tableContext = resolveTableDirection({}, sectionContext); + expect(tableContext.visualDirection).toBeUndefined(); + }); + + it('table with rightToLeft:true wins when both signals present', () => { + // Mixed shape (one true, one false) should NOT happen in practice but + // the rtl branch is checked first to keep this case explicit. SD-3141. + const sectionContext = resolveSectionDirection(undefined); + const tableContext = resolveTableDirection({ rightToLeft: true, bidiVisual: false }, sectionContext); + expect(tableContext.visualDirection).toBe('rtl'); + }); }); describe('Non-collapse rule 3: run-level w:rtl MUST NOT bubble up to paragraph', () => { diff --git a/packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts b/packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts index 9fb640b6b7..c8fa067dc9 100644 --- a/packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts +++ b/packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts @@ -27,8 +27,14 @@ export const resolveTableDirection = ( parentSection: SectionDirectionContext, ): TableDirectionContext => { let visualDirection: BaseDirection | undefined; + // Mirror the paragraph resolver shape (resolveParagraphDirection): explicit + // false is a real signal and must be distinguished from "no signal." Per + // ECMA-376 §17.4.1 + §17.17.4, w:bidiVisual w:val="0" is an explicit-false + // that can override a style-cascade true. SD-3141. if (tableProperties?.rightToLeft === true || tableProperties?.bidiVisual === true) { visualDirection = 'rtl'; + } else if (tableProperties?.rightToLeft === false || tableProperties?.bidiVisual === false) { + visualDirection = 'ltr'; } return { visualDirection, parentSection }; };