diff --git a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js new file mode 100644 index 0000000000..3a10964fd4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js @@ -0,0 +1,33 @@ +/** + * Maps display alignment (UI-facing physical left/right/center/justify) to + * stored OOXML paragraph justification, honoring RTL paragraph direction. + * + * @param {'left' | 'center' | 'right' | 'justify'} alignment + * @param {boolean} isRtl + * @returns {'left' | 'center' | 'right' | 'both'} + */ +export function mapDisplayAlignmentToStoredJustification(alignment, isRtl) { + if (alignment === 'justify') return 'both'; + if (!isRtl) return alignment; + if (alignment === 'left') return 'right'; + if (alignment === 'right') return 'left'; + return alignment; +} + +/** + * Maps stored OOXML paragraph justification to display alignment, honoring + * RTL paragraph direction. When justification is absent, returns the + * visual default by direction. + * + * @param {string | null | undefined} justification + * @param {boolean} isRtl + * @returns {'left' | 'center' | 'right' | 'justify'} + */ +export function mapStoredJustificationToDisplayAlignment(justification, isRtl) { + if (!justification) return isRtl ? 'right' : 'left'; + if (justification === 'both') return 'justify'; + if (!isRtl) return /** @type {'left' | 'center' | 'right' | 'justify'} */ (justification); + if (justification === 'left') return 'right'; + if (justification === 'right') return 'left'; + return /** @type {'left' | 'center' | 'right' | 'justify'} */ (justification); +} diff --git a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js new file mode 100644 index 0000000000..77c1fee0bb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + mapDisplayAlignmentToStoredJustification, + mapStoredJustificationToDisplayAlignment, +} from './paragraph-alignment.js'; + +// SD-3094: per ECMA-376 §17.3.1.13, `w:jc="left"` is the leading edge of the +// paragraph, not the physical left. In RTL paragraphs (`w:bidi`), leading is +// the right side. The two helpers below own the display ↔ stored translation +// so the editor can keep its UI in physical terms while the model stays in the +// spec's logical terms. + +describe('mapDisplayAlignmentToStoredJustification', () => { + describe('LTR paragraphs (isRtl=false)', () => { + it('passes left/right/center through unchanged', () => { + expect(mapDisplayAlignmentToStoredJustification('left', false)).toBe('left'); + expect(mapDisplayAlignmentToStoredJustification('right', false)).toBe('right'); + expect(mapDisplayAlignmentToStoredJustification('center', false)).toBe('center'); + }); + + it('maps justify to OOXML both', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', false)).toBe('both'); + }); + }); + + describe('RTL paragraphs (isRtl=true)', () => { + it('mirrors left to right', () => { + expect(mapDisplayAlignmentToStoredJustification('left', true)).toBe('right'); + }); + + it('mirrors right to left', () => { + expect(mapDisplayAlignmentToStoredJustification('right', true)).toBe('left'); + }); + + it('keeps center unchanged', () => { + expect(mapDisplayAlignmentToStoredJustification('center', true)).toBe('center'); + }); + + it('maps justify to OOXML both (no direction-flip)', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', true)).toBe('both'); + }); + }); + + it('justify always maps to both regardless of direction', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', false)).toBe('both'); + expect(mapDisplayAlignmentToStoredJustification('justify', true)).toBe('both'); + }); +}); + +describe('mapStoredJustificationToDisplayAlignment', () => { + describe('LTR paragraphs (isRtl=false)', () => { + it('passes left/right/center through unchanged', () => { + expect(mapStoredJustificationToDisplayAlignment('left', false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment('right', false)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment('center', false)).toBe('center'); + }); + + it('maps both to justify', () => { + expect(mapStoredJustificationToDisplayAlignment('both', false)).toBe('justify'); + }); + + it('defaults to left when justification is absent', () => { + expect(mapStoredJustificationToDisplayAlignment(null, false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment(undefined, false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment('', false)).toBe('left'); + }); + }); + + describe('RTL paragraphs (isRtl=true)', () => { + it('mirrors stored left to display right', () => { + expect(mapStoredJustificationToDisplayAlignment('left', true)).toBe('right'); + }); + + it('mirrors stored right to display left', () => { + expect(mapStoredJustificationToDisplayAlignment('right', true)).toBe('left'); + }); + + it('keeps center unchanged', () => { + expect(mapStoredJustificationToDisplayAlignment('center', true)).toBe('center'); + }); + + it('maps both to justify (no direction-flip)', () => { + expect(mapStoredJustificationToDisplayAlignment('both', true)).toBe('justify'); + }); + + it('defaults to right when justification is absent', () => { + expect(mapStoredJustificationToDisplayAlignment(null, true)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment(undefined, true)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment('', true)).toBe('right'); + }); + }); + + // Roundtrip: writing then reading must return the original display value. + describe('roundtrip display → stored → display', () => { + for (const isRtl of [false, true]) { + for (const display of /** @type {const} */ (['left', 'center', 'right', 'justify'])) { + it(`${display} on ${isRtl ? 'RTL' : 'LTR'} survives a write+read cycle`, () => { + const stored = mapDisplayAlignmentToStoredJustification(display, isRtl); + const readBack = mapStoredJustificationToDisplayAlignment(stored, isRtl); + expect(readBack).toBe(display); + }); + } + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index db2ddc843a..eea4510f58 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -34,6 +34,7 @@ const mockedDeps = vi.hoisted(() => ({ applyDirectMutationMeta: vi.fn(), applyTrackedMutationMeta: vi.fn(), mapBlockNodeType: vi.fn(), + calculateResolvedParagraphProperties: vi.fn((_editor: Editor, node: any) => node?.attrs?.paragraphProperties ?? {}), })); vi.mock('../helpers/index-cache.js', () => ({ @@ -71,6 +72,10 @@ vi.mock('../helpers/node-address-resolver.js', () => ({ candidate.nodeType === 'tableCell', })); +vi.mock('../../extensions/paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: mockedDeps.calculateResolvedParagraphProperties, +})); + // Register built-in executors once beforeAll(() => { registerBuiltInExecutors(); @@ -81,6 +86,9 @@ beforeEach(() => { mockedDeps.getRevision.mockReturnValue('0'); mockedDeps.incrementRevision.mockReturnValue('1'); mockedDeps.mapBlockNodeType.mockReturnValue(undefined); + mockedDeps.calculateResolvedParagraphProperties.mockImplementation((_editor: Editor, node: any) => { + return node?.attrs?.paragraphProperties ?? {}; + }); }); // --------------------------------------------------------------------------- @@ -3015,6 +3023,107 @@ describe('executeStyleApply: collapsed-range no-op guard', () => { expect(result).toEqual({ changed: false }); expect(tr.addMark).not.toHaveBeenCalled(); }); + + it('mirrors left/right alignment for RTL paragraphs when applying format.alignment', () => { + const tr = { + setNodeMarkup: vi.fn(), + doc: { + nodesBetween: vi.fn((from: number, to: number, callback: (node: any, pos: number) => void) => { + callback( + { + isTextblock: true, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'left', + }, + }, + nodeSize: 8, + }, + 5, + ); + }), + }, + } as any; + + const editor = {} as Editor; + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 6, + absTo: 10, + }) as any; + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-rtl-align', + ref: 'test-ref', + args: { alignment: 'left' as any }, + }; + + const result = executeStyleApply(editor, tr, target, step, { map: (pos: number) => pos } as any); + + expect(result).toEqual({ changed: true }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith( + 5, + undefined, + expect.objectContaining({ + paragraphProperties: expect.objectContaining({ + rightToLeft: true, + justification: 'right', + }), + }), + ); + }); + + it('uses resolved RTL from style cascade when applying format.alignment', () => { + mockedDeps.calculateResolvedParagraphProperties.mockReturnValueOnce({ rightToLeft: true }); + const tr = { + setNodeMarkup: vi.fn(), + doc: { + resolve: vi.fn((pos: number) => ({ pos })), + nodesBetween: vi.fn((from: number, to: number, callback: (node: any, pos: number) => void) => { + callback( + { + isTextblock: true, + attrs: { + paragraphProperties: { + rightToLeft: false, + justification: 'left', + }, + }, + nodeSize: 8, + }, + 5, + ); + }), + }, + } as any; + + const editor = {} as Editor; + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 6, + absTo: 10, + }) as any; + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-rtl-align-resolved', + ref: 'test-ref', + args: { alignment: 'left' as any }, + }; + + const result = executeStyleApply(editor, tr, target, step, { map: (pos: number) => pos } as any); + + expect(result).toEqual({ changed: true }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith( + 5, + undefined, + expect.objectContaining({ + paragraphProperties: expect.objectContaining({ + justification: 'right', + }), + }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 97eacb604f..31118612e3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -39,7 +39,7 @@ import type { } from './executor-registry.types.js'; import { getStepExecutor } from './executor-registry.js'; import { planError } from './errors.js'; -import { ALIGNMENT_TO_JUSTIFICATION } from './paragraphs-wrappers.js'; +import { mapAlignmentToJustificationForParagraph } from './paragraphs-wrappers.js'; import { closeHistory } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; import { checkRevision, getRevision } from './revision-tracker.js'; @@ -53,6 +53,7 @@ import { mapBlockNodeType } from '../helpers/node-address-resolver.js'; import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; import { normalizeReplacementText } from './replacement-normalizer.js'; import { getWordChanges } from './word-diff.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; import { Fragment, Slice } from 'prosemirror-model'; import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; @@ -1067,16 +1068,19 @@ export function executeTextDelete( return { changed: true }; } -// ALIGNMENT_TO_JUSTIFICATION imported from paragraphs-wrappers.js - /** * Applies alignment to the paragraph node(s) that contain the given range. * Uses the same mechanism as paragraphsSetAlignmentWrapper: updates * paragraphProperties.justification via tr.setNodeMarkup. */ -function applyAlignmentToRange(tr: Transaction, absFrom: number, absTo: number, alignment: string): boolean { - const justification = ALIGNMENT_TO_JUSTIFICATION[alignment as keyof typeof ALIGNMENT_TO_JUSTIFICATION]; - if (!justification) return false; +function applyAlignmentToRange( + editor: Editor, + tr: Transaction, + absFrom: number, + absTo: number, + alignment: string, +): boolean { + if (!alignment) return false; let changed = false; const doc = tr.doc; @@ -1086,6 +1090,9 @@ function applyAlignmentToRange(tr: Transaction, absFrom: number, absTo: number, if (!node.isTextblock) return; const existing = (node.attrs as Record).paragraphProperties as Record | undefined; + const paragraphPos = typeof tr.doc.resolve === 'function' ? tr.doc.resolve(pos) : null; + const resolved = calculateResolvedParagraphProperties(editor, node, paragraphPos as any); + const justification = mapAlignmentToJustificationForParagraph(alignment as any, resolved?.rightToLeft === true); const currentJustification = existing?.justification; if (currentJustification === justification) return; @@ -1145,7 +1152,7 @@ export function executeStyleApply( } if (step.args.alignment) { - changed = applyAlignmentToRange(tr, absFrom, absTo, step.args.alignment) || changed; + changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed; } return { changed }; @@ -1305,7 +1312,7 @@ export function executeSpanStyleApply( } if (step.args.alignment) { - changed = applyAlignmentToRange(tr, absFrom, absTo, step.args.alignment) || changed; + changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed; } return { changed }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts index ccf0f6e73b..b69e7ef3ed 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean) => { @@ -21,7 +22,15 @@ vi.mock('./plan-wrappers.js', () => ({ }), })); -import { paragraphsSetIndentationWrapper, paragraphsSetStyleWrapper } from './paragraphs-wrappers.js'; +vi.mock('../../extensions/paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: vi.fn((_editor, node) => node?.attrs?.paragraphProperties ?? {}), +})); + +import { + paragraphsSetIndentationWrapper, + paragraphsSetStyleWrapper, + paragraphsSetAlignmentWrapper, +} from './paragraphs-wrappers.js'; type MockNode = { type: { name: 'paragraph' | 'text' }; @@ -218,3 +227,32 @@ describe('paragraphsSetStyleWrapper', () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +describe('paragraphsSetAlignmentWrapper', () => { + it('mirrors left/right alignment for RTL paragraphs when writing justification', () => { + const { editor, setNodeMarkup } = makeEditor({ + rightToLeft: true, + }); + + paragraphsSetAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + alignment: 'left', + }); + + const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; + expect(nextAttrs.paragraphProperties.justification).toBe('right'); + }); + + it('uses resolved RTL from style cascade when raw paragraph attrs are LTR/empty', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const { editor, setNodeMarkup } = makeEditor({}); + + paragraphsSetAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + alignment: 'left', + }); + + const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; + expect(nextAttrs.paragraphProperties.justification).toBe('right'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts index 5f26e674e2..a4be4006c9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -44,6 +44,8 @@ import { findBlockByIdStrict, type BlockCandidate } from '../helpers/node-addres import { DocumentApiAdapterError } from '../errors.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { mapDisplayAlignmentToStoredJustification } from '../../core/helpers/paragraph-alignment.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; // --------------------------------------------------------------------------- // Paragraph block types accepted by this adapter @@ -194,7 +196,7 @@ function mutateParagraphProperties( candidate: BlockCandidate, operation: string, target: ParagraphTarget, - transform: (pPr: PPr) => PPr, + transform: (pPr: PPr, node: any, pos: number) => PPr, options?: MutationOptions, extras?: { clearDirectFormatting?: boolean; @@ -209,7 +211,7 @@ function mutateParagraphProperties( if (!node) return false; const existing = (node.attrs as { paragraphProperties?: PPr }).paragraphProperties ?? {}; - const updated = transform({ ...existing }); + const updated = transform({ ...existing }, node, candidate.pos); if (JSON.stringify(existing) === JSON.stringify(updated)) return false; @@ -238,12 +240,9 @@ function mutateParagraphProperties( // Alignment mapping — external API → OOXML justification value // --------------------------------------------------------------------------- -export const ALIGNMENT_TO_JUSTIFICATION: Record = { - left: 'left', - center: 'center', - right: 'right', - justify: 'both', -}; +export function mapAlignmentToJustificationForParagraph(alignment: ParagraphAlignment, isRtl?: boolean): string { + return mapDisplayAlignmentToStoredJustification(alignment, isRtl); +} // --------------------------------------------------------------------------- // Property helpers @@ -384,10 +383,14 @@ export function paragraphsSetAlignmentWrapper( candidate, 'format.paragraph.setAlignment', input.target, - (pPr) => ({ - ...pPr, - justification: ALIGNMENT_TO_JUSTIFICATION[input.alignment], - }), + (pPr, node, pos) => { + const paragraphPos = typeof editor.state.doc.resolve === 'function' ? editor.state.doc.resolve(pos) : null; + const resolved = calculateResolvedParagraphProperties(editor, node, paragraphPos as any); + return { + ...pPr, + justification: mapAlignmentToJustificationForParagraph(input.alignment, resolved?.rightToLeft === true), + }; + }, options, ); } diff --git a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js index de16fe2bbf..7f410692d2 100644 --- a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js +++ b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js @@ -1,5 +1,7 @@ // @ts-nocheck import { Extension } from '@core/Extension.js'; +import { mapDisplayAlignmentToStoredJustification } from '../../core/helpers/paragraph-alignment.js'; +import { calculateResolvedParagraphProperties } from '../paragraph/resolvedPropertiesCache.js'; /** * Configuration options for TextAlign @@ -39,11 +41,34 @@ export const TextAlign = Extension.create({ */ setTextAlign: (alignment) => - ({ commands }) => { + ({ commands, state }) => { const containsAlignment = this.options.alignments.includes(alignment); if (!containsAlignment) return false; + const $from = state?.selection?.$from; + let paragraphNode = null; + let paragraphDepth = -1; - return commands.updateAttributes('paragraph', { 'paragraphProperties.justification': alignment }); + if ($from) { + for (let depth = $from.depth; depth >= 0; depth--) { + const nodeAtDepth = $from.node(depth); + if (nodeAtDepth?.type?.name === 'paragraph') { + paragraphNode = nodeAtDepth; + paragraphDepth = depth; + break; + } + } + } + + let paragraphProperties = paragraphNode?.attrs?.paragraphProperties ?? {}; + + if (this.editor && $from && paragraphNode && paragraphDepth > 0) { + const paragraphStartPos = $from.before(paragraphDepth); + const paragraphPos = state.doc.resolve(paragraphStartPos); + paragraphProperties = calculateResolvedParagraphProperties(this.editor, paragraphNode, paragraphPos); + } + + const storedAlignment = mapDisplayAlignmentToStoredJustification(alignment, paragraphProperties?.rightToLeft); + return commands.updateAttributes('paragraph', { 'paragraphProperties.justification': storedAlignment }); }, /** diff --git a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js new file mode 100644 index 0000000000..24b66c9e53 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { calculateResolvedParagraphProperties } from '../paragraph/resolvedPropertiesCache.js'; +import { TextAlign } from './text-align.js'; + +vi.mock('../paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: vi.fn((_editor, node) => node?.attrs?.paragraphProperties ?? {}), +})); + +describe('TextAlign extension', () => { + const extensionContext = { + options: TextAlign.config.addOptions(), + editor: { converter: { translatedNumbering: {}, translatedLinkedStyles: {} } }, + }; + const commands = TextAlign.config.addCommands.call({ + ...extensionContext, + }); + + const makeState = (paragraphProperties = {}) => ({ + doc: { + resolve: vi.fn((pos) => ({ pos })), + }, + selection: { + $from: { + depth: 1, + before: vi.fn(() => 0), + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { paragraphProperties }, + } + : { type: { name: 'doc' }, attrs: {} }, + ), + parent: { + type: { name: 'paragraph' }, + attrs: { paragraphProperties }, + }, + }, + }, + }); + + it('writes alignment as-is for LTR paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: false }), + }); + + expect(result).toBe(true); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'left', + }); + }); + + it('mirrors left/right for RTL paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + const leftResult = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + const rightResult = commands.setTextAlign('right')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + expect(leftResult).toBe(true); + expect(rightResult).toBe(true); + expect(updateAttributes).toHaveBeenNthCalledWith(1, 'paragraph', { + 'paragraphProperties.justification': 'right', + }); + expect(updateAttributes).toHaveBeenNthCalledWith(2, 'paragraph', { + 'paragraphProperties.justification': 'left', + }); + }); + + it('uses resolved RTL from style cascade when raw paragraph attrs are LTR/empty', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({}), + }); + + expect(result).toBe(true); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'right', + }); + }); + + it('resolves paragraph ancestor when selection parent is run', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const updateAttributes = vi.fn(() => true); + const state = { + doc: { + resolve: vi.fn((pos) => ({ pos })), + }, + selection: { + $from: { + depth: 2, + before: vi.fn((depth) => (depth === 1 ? 5 : 0)), + node: vi.fn((depth) => { + if (depth === 2) return { type: { name: 'run' }, attrs: {} }; + if (depth === 1) + return { type: { name: 'paragraph' }, attrs: { paragraphProperties: { rightToLeft: false } } }; + return { type: { name: 'doc' }, attrs: {} }; + }), + parent: { type: { name: 'run' }, attrs: {} }, + }, + }, + }; + + commands.setTextAlign('left')({ + commands: { updateAttributes }, + state, + }); + + expect(calculateResolvedParagraphProperties).toHaveBeenCalled(); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'right', + }); + }); + + it('keeps center and justify unchanged for RTL paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + commands.setTextAlign('center')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + commands.setTextAlign('justify')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + expect(updateAttributes).toHaveBeenNthCalledWith(1, 'paragraph', { + 'paragraphProperties.justification': 'center', + }); + expect(updateAttributes).toHaveBeenNthCalledWith(2, 'paragraph', { + 'paragraphProperties.justification': 'both', + }); + }); + + it('returns false for unsupported alignment values', () => { + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('start')({ + commands: { updateAttributes }, + state: makeState(), + }); + + expect(result).toBe(false); + expect(updateAttributes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 3875abd8e3..dc188c5564 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -3,18 +3,16 @@ import { numberingInfoToOrderedStyle } from '../../editors/v1/core/helpers/list- import type { OrderedListStyle } from '../../editors/v1/extensions/types/paragraph-commands.js'; import { twipsToLines } from '../../editors/v1/core/super-converter/helpers.js'; import { getQuickFormatList } from '../../editors/v1/extensions/linked-styles/index.js'; +import { mapStoredJustificationToDisplayAlignment } from '../../editors/v1/core/helpers/paragraph-alignment.js'; import { getCurrentParagraphParent, getCurrentResolvedParagraphProperties, resolveStateEditor } from './context.js'; import { createDirectCommandExecute, isCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; const getCurrentParagraphJustification = (context: ToolbarContext | null) => { - const justification = getCurrentResolvedParagraphProperties(context)?.justification ?? null; - - if (justification === 'both') { - return 'justify'; - } - - return justification; + const paragraphProperties = getCurrentResolvedParagraphProperties(context); + const justification = paragraphProperties?.justification ?? null; + const isRtl = paragraphProperties?.rightToLeft === true; + return mapStoredJustificationToDisplayAlignment(justification, isRtl); }; export const createTextAlignStateDeriver = diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 3044ddf41e..9813e30937 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -376,6 +376,137 @@ describe('createToolbarRegistry', () => { }); }); + it('derives mirrored text-align for RTL paragraph with explicit right justification', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'right', + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'left', + }); + }); + + it('derives mirrored text-align for RTL paragraph with explicit left justification', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'left', + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'right', + }); + }); + + it('defaults text-align to right for RTL paragraph when justification is missing', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'right', + }); + }); + it('derives line-height value from paragraph spacing', () => { const registry = createToolbarRegistry(); const state = registry['line-height']?.state({ diff --git a/tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx b/tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx new file mode 100644 index 0000000000..9a0ca9dc20 Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx differ 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/fixtures/rtl-section-only-bidi.docx b/tests/behavior/tests/formatting/fixtures/rtl-section-only-bidi.docx new file mode 100644 index 0000000000..eb4968edc9 Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-section-only-bidi.docx differ diff --git a/tests/behavior/tests/formatting/fixtures/rtl-table-cell-alignment.docx b/tests/behavior/tests/formatting/fixtures/rtl-table-cell-alignment.docx new file mode 100644 index 0000000000..1756984a2b Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-table-cell-alignment.docx differ diff --git a/tests/behavior/tests/toolbar/alignment-rtl-headings.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl-headings.spec.ts new file mode 100644 index 0000000000..1277860a59 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl-headings.spec.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-heading-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: setTextAlign + the doc-api setAlignment paths both apply to +// heading nodes (not just body paragraphs). Verify the RTL mirror logic +// works the same for headings as for paragraphs when w:bidi is set. + +test('Align Left on RTL heading stores OOXML w:jc="right" (writer mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const heading = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'Heading 1' }) + .first(); + await heading.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('Heading 1', 'right'); +}); + +test('Align Right on RTL heading stores OOXML w:jc="left" (writer mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const heading = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'Heading 2' }) + .first(); + await heading.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('Heading 2', 'left'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts new file mode 100644 index 0000000000..129488ab84 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: changing alignment across a selection that spans multiple RTL +// paragraphs must apply the mirror per-paragraph (each paragraph is its +// own bidi context). Selecting all three Hebrew paragraphs in the +// fixture and clicking Align Left should write w:jc="right" on all of +// them. + +test('Align Left across a 3-paragraph RTL selection mirrors each paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + // Use the doc-api to select all text (covers all three paragraphs). + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const docSize = editor.state.doc.content.size; + editor.commands.setTextSelection({ from: 1, to: docSize - 1 }); + }); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.waitForStable(); + + // All three RTL paragraphs should now store w:jc="right". + await superdoc.assertTextAlignment('jc=left', 'right'); + await superdoc.assertTextAlignment('jc=right', 'right'); + await superdoc.assertTextAlignment('jc=center', 'right'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts new file mode 100644 index 0000000000..71bbe83b15 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-table-cell-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: paragraphs inside RTL-direction table cells should be treated +// the same as standalone RTL paragraphs by the alignment writer. The +// fixture is a bidiVisual table with Hebrew cell content; clicking +// Align Left on a cell paragraph should store w:jc="right" if the cell +// content is recognized as RTL. + +test('Align Left on a Hebrew cell stores w:jc="right" (writer mirror under cell-level RTL)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const cellLine = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'תא 1' }) + .first(); + await cellLine.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('תא 1', 'right'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-rtl.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl.spec.ts new file mode 100644 index 0000000000..3aa37165e2 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl.spec.ts @@ -0,0 +1,81 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RTL_DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: toolbar writer must mirror display alignment to the stored OOXML +// value on RTL paragraphs (per ECMA-376 §17.3.1.13: left = leading edge = +// stored 'right' in RTL). Visual rendering is verified separately by the +// rtl-paragraph-alignment-import.spec.ts that ships with SD-3093. + +test('clicking Align Left on RTL paragraph stores OOXML w:jc="right"', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('jc=center', 'right'); +}); + +test('clicking Align Right on RTL paragraph stores OOXML w:jc="left"', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('jc=center', 'left'); +}); + +test('clicking Justify on RTL paragraph stores OOXML w:jc="both" (normalized)', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Justify'); + // The doc-api normalizes OOXML 'both' to 'justify' on read. + await superdoc.assertTextAlignment('jc=center', 'justify'); +}); + +test('clicking Align Center on RTL paragraph stores OOXML w:jc="center" (no mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=left' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align center'); + await superdoc.assertTextAlignment('jc=left', 'center'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts b/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts new file mode 100644 index 0000000000..77fbd824a8 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SECTION_BIDI_DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-section-only-bidi.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094 + ECMA-376 §17.6.1 (section bidi): when the section has w:bidi +// but the paragraph itself does NOT, the section bidi must NOT affect +// paragraph w:jc interpretation. The spec is explicit: "This property +// only affects section-level properties, and does not affect the layout +// of text within the contents of this section." +// +// Practical implication: clicking "Align Left" on such a paragraph +// should write w:jc="left" unchanged (the paragraph isn't RTL even +// though its section is). +test('Align Left on a non-bidi paragraph inside an RTL section stores w:jc="left" unchanged', async ({ superdoc }) => { + await superdoc.loadDocument(SECTION_BIDI_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=right' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + // No mirror: stored value is 'left' (the display value), not 'right'. + await superdoc.assertTextAlignment('jc=right', 'left'); +}); + +test('Align Right on a non-bidi paragraph inside an RTL section stores w:jc="right" unchanged', async ({ + superdoc, +}) => { + await superdoc.loadDocument(SECTION_BIDI_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=left' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('jc=left', 'right'); +}); diff --git a/tests/behavior/tests/toolbar/indent-rtl.spec.ts b/tests/behavior/tests/toolbar/indent-rtl.spec.ts new file mode 100644 index 0000000000..5e3b2a390e --- /dev/null +++ b/tests/behavior/tests/toolbar/indent-rtl.spec.ts @@ -0,0 +1,54 @@ +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.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// SD-3094 + ECMA-376 §17.3.1.6: w:bidi affects w:ind the same way it +// affects w:jc. Per the spec, increasing indent on an RTL paragraph +// should grow the leading edge (the right visual side). This spec is +// a smoke test that the Increase Indent toolbar action runs without +// error on an RTL paragraph and produces a paragraph-properties update +// observable through the doc-api. + +test('Increase Indent on RTL paragraph updates paragraph properties', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + const before = await superdoc.page.evaluate(() => { + const docApi = (window as any).editor?.doc; + const m = docApi.query.match({ select: { type: 'text', pattern: 'jc=center' }, require: 'first' }); + const addr = m?.items?.[0]?.address; + if (!addr) return null; + const n = docApi.getNode(addr); + const p = (n?.node ?? n).paragraph; + return { indent: p?.props?.indent ?? null }; + }); + + await superdoc.page.locator('[data-item="btn-indentright"]').click(); + await superdoc.waitForStable(); + + const after = await superdoc.page.evaluate(() => { + const docApi = (window as any).editor?.doc; + const m = docApi.query.match({ select: { type: 'text', pattern: 'jc=center' }, require: 'first' }); + const addr = m?.items?.[0]?.address; + if (!addr) return null; + const n = docApi.getNode(addr); + const p = (n?.node ?? n).paragraph; + return { indent: p?.props?.indent ?? null }; + }); + + // The indent must change (an exact value depends on the existing + // indent + step size; we only assert it is no longer the original). + expect(JSON.stringify(after?.indent)).not.toBe(JSON.stringify(before?.indent)); +}); diff --git a/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts new file mode 100644 index 0000000000..7d23a65ab9 --- /dev/null +++ b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts @@ -0,0 +1,202 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +function extractParagraphXmls(documentXml: string): string[] { + return [...documentXml.matchAll(//g)].map((match) => match[0]); +} + +function countMatches(source: string, pattern: RegExp): number { + return [...source.matchAll(pattern)].length; +} + +function makeSessionId(label: string): string { + return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// SD-3094 / SD-3093: When a paragraph is RTL (w:bidi), the doc-api +// `format.paragraph.setAlignment` takes a *display* alignment (what the user +// sees) and must write the spec-correct *stored* w:jc value per ECMA-376 +// §17.3.1.13 (left = leading edge, right = trailing edge). +describe('document-api story: rtl paragraph alignment write', () => { + const { client, outPath } = useStoryHarness('formatting/rtl-alignment-api', { + preserveResults: true, + }); + + const api = client as any; + + async function openBlankWithText(sessionId: string, text: string): Promise { + await api.doc.open({ sessionId }); + const insertResult = unwrap(await api.doc.insert({ sessionId, value: text })); + expect(insertResult?.receipt?.success).toBe(true); + } + + async function paragraphTargetForText(sessionId: string, text: string) { + const result = unwrap( + await api.doc.query.match({ + sessionId, + select: { type: 'text', pattern: text, caseSensitive: true }, + require: 'first', + }), + ); + const item = result?.items?.[0]; + expect(item?.address?.kind).toBe('block'); + expect(item?.address?.nodeType).toBe('paragraph'); + return item.address; + } + + async function makeRtlParagraph(sessionId: string, text: string) { + await openBlankWithText(sessionId, text); + const target = await paragraphTargetForText(sessionId, text); + const result = unwrap( + await api.doc.format.paragraph.setDirection({ + sessionId, + target, + direction: 'rtl', + alignmentPolicy: 'preserve', + }), + ); + expect(result?.success).toBe(true); + return target; + } + + async function saveResult(sessionId: string, name: string): Promise { + const savePath = outPath(name); + await api.doc.save({ sessionId, out: savePath, force: true }); + return savePath; + } + + it('setAlignment(left) on RTL paragraph exports w:jc=right (mirrored to trailing-edge stored value)', async () => { + const sessionId = makeSessionId('rtl-align-left'); + const paragraphText = 'RTL paragraph align-left case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'left', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-left.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(0); + }); + + it('setAlignment(right) on RTL paragraph exports w:jc=left (mirrored to leading-edge stored value)', async () => { + const sessionId = makeSessionId('rtl-align-right'); + const paragraphText = 'RTL paragraph align-right case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'right', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-right.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(0); + }); + + it('setAlignment(center) on RTL paragraph exports w:jc=center (no mirror)', async () => { + const sessionId = makeSessionId('rtl-align-center'); + const paragraphText = 'RTL paragraph align-center case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'center', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-center.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="center"[^>]*\/>/g)).toBe(1); + }); + + it('setAlignment(justify) on RTL paragraph exports w:jc=both (justify normalizes to both, no mirror)', async () => { + const sessionId = makeSessionId('rtl-align-justify'); + const paragraphText = 'RTL paragraph align-justify case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'justify', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-justify.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="both"[^>]*\/>/g)).toBe(1); + }); + + it('setAlignment on LTR paragraph still writes display value unchanged', async () => { + const sessionId = makeSessionId('ltr-align-baseline'); + const paragraphText = 'LTR paragraph baseline case'; + + await openBlankWithText(sessionId, paragraphText); + const target = await paragraphTargetForText(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'left', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'ltr-align-left.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(1); + }); +});