diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts index 0ad57727a7..05e919856f 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts @@ -114,9 +114,9 @@ describe('normalizeAlignment', () => { expect(normalizeAlignment('end', true)).toBe('left'); }); - it('does not flip explicit left/right/center/justify in RTL', () => { - expect(normalizeAlignment('left', true)).toBe('left'); - expect(normalizeAlignment('right', true)).toBe('right'); + it('maps explicit left/right to logical start/end in RTL', () => { + expect(normalizeAlignment('left', true)).toBe('right'); + expect(normalizeAlignment('right', true)).toBe('left'); expect(normalizeAlignment('center', true)).toBe('center'); expect(normalizeAlignment('justify', true)).toBe('justify'); }); @@ -127,6 +127,22 @@ describe('normalizeAlignment', () => { expect(normalizeAlignment('highKashida')).toBe('justify'); }); + // SD-3093: both/distribute/numTab/thaiDistribute collapse to justify regardless + // of direction. They must not flip under RTL like `left`/`right` do. + it('maps both/distribute/numTab/thaiDistribute to justify in LTR', () => { + expect(normalizeAlignment('both', false)).toBe('justify'); + expect(normalizeAlignment('distribute', false)).toBe('justify'); + expect(normalizeAlignment('numTab', false)).toBe('justify'); + expect(normalizeAlignment('thaiDistribute', false)).toBe('justify'); + }); + + it('maps both/distribute/numTab/thaiDistribute to justify in RTL (no flip)', () => { + expect(normalizeAlignment('both', true)).toBe('justify'); + expect(normalizeAlignment('distribute', true)).toBe('justify'); + expect(normalizeAlignment('numTab', true)).toBe('justify'); + expect(normalizeAlignment('thaiDistribute', true)).toBe('justify'); + }); + it('returns undefined for invalid values', () => { expect(normalizeAlignment('unknown')).toBeUndefined(); expect(normalizeAlignment(123)).toBeUndefined(); diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index 426e10ad03..5d488af698 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -43,10 +43,12 @@ const AUTO_SPACING_LINE_DEFAULT = 240; // Default OOXML auto line spacing in twi export const normalizeAlignment = (value: unknown, isRtl = false): ParagraphAttrs['alignment'] => { switch (value) { case 'center': - case 'right': case 'justify': - case 'left': return value; + case 'left': + return isRtl ? 'right' : 'left'; + case 'right': + return isRtl ? 'left' : 'right'; case 'both': case 'distribute': case 'numTab': diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 510578a7e4..af949c4f0c 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -4655,7 +4655,7 @@ describe('toFlowBlocks', () => { }); }); - it('preserves explicit left alignment on RTL paragraphs', () => { + it('maps explicit left alignment to right on RTL paragraphs', () => { const pmDoc = { type: 'doc', content: [ @@ -4680,6 +4680,37 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); + expect(blocks).toHaveLength(1); + expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs).toMatchObject({ + alignment: 'right', + }); + }); + + it('maps explicit right alignment to left on RTL paragraphs', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'right', + }, + }, + content: [ + { + type: 'text', + text: 'مرحبا بالعالم', + }, + ], + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc); + expect(blocks).toHaveLength(1); expect(blocks[0].attrs?.direction).toBe('rtl'); expect(blocks[0].attrs).toMatchObject({ @@ -4726,6 +4757,29 @@ describe('toFlowBlocks', () => { expect(blocksStart[0].attrs?.alignment).toBe('right'); expect(blocksEnd[0].attrs?.alignment).toBe('left'); }); + + // SD-3093: justify-family values must collapse to 'justify' without flipping + // in RTL. Regression guard against accidentally extending the mirror logic. + it('maps both/distribute/numTab/thaiDistribute to justify on RTL paragraphs', () => { + const makeDoc = (jc: string) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { rightToLeft: true, justification: jc }, + }, + content: [{ type: 'text', text: 'مرحبا' }], + }, + ], + }); + + for (const jc of ['both', 'distribute', 'numTab', 'thaiDistribute']) { + const { blocks } = toFlowBlocks(makeDoc(jc)); + expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs).toMatchObject({ alignment: 'justify' }); + } + }); }); describe('documentSection SDT metadata propagation', () => { diff --git a/tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx b/tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx new file mode 100644 index 0000000000..b3f138066f Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx differ diff --git a/tests/behavior/tests/formatting/rtl-paragraph-alignment-import.spec.ts b/tests/behavior/tests/formatting/rtl-paragraph-alignment-import.spec.ts new file mode 100644 index 0000000000..84fbf5546e --- /dev/null +++ b/tests/behavior/tests/formatting/rtl-paragraph-alignment-import.spec.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/rtl-paragraph-alignment.docx'); + +// SD-3093: When a Word doc has `w:bidi` + explicit `w:jc="left"`/"right"/"center", +// ECMA-376 §17.3.1.13 says left = leading edge, right = trailing edge. In an RTL +// paragraph that resolves to visual right / visual left / center respectively. +// This spec loads a Word-authored fixture exercising all three to guard the +// import + render path that PR #3235 fixes. +test('RTL paragraph w:jc=left renders text-align: right', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const lines = superdoc.page.locator('.superdoc-page .superdoc-fragment .superdoc-line'); + const jcLeftLine = lines.filter({ hasText: 'jc=left' }).first(); + await expect(jcLeftLine).toBeVisible(); + const textAlign = await jcLeftLine.evaluate((el) => window.getComputedStyle(el).textAlign); + expect(textAlign).toBe('right'); +}); + +test('RTL paragraph w:jc=right renders text-align: left', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const lines = superdoc.page.locator('.superdoc-page .superdoc-fragment .superdoc-line'); + const jcRightLine = lines.filter({ hasText: 'jc=right' }).first(); + await expect(jcRightLine).toBeVisible(); + const textAlign = await jcRightLine.evaluate((el) => window.getComputedStyle(el).textAlign); + expect(textAlign).toBe('left'); +}); + +test('RTL paragraph w:jc=center renders text-align: center', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const lines = superdoc.page.locator('.superdoc-page .superdoc-fragment .superdoc-line'); + const jcCenterLine = lines.filter({ hasText: 'jc=center' }).first(); + await expect(jcCenterLine).toBeVisible(); + const textAlign = await jcCenterLine.evaluate((el) => window.getComputedStyle(el).textAlign); + expect(textAlign).toBe('center'); +});