From 18412cfdc96b5afcceb0d426eb20ec3ef94d2d95 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Wed, 8 Apr 2026 21:22:38 +0300 Subject: [PATCH 1/4] fix: centered image --- .../layout-engine/src/layout-drawing.test.ts | 63 ++++++++++++++ .../layout-engine/src/layout-drawing.ts | 14 +++- .../src/converters/paragraph.test.ts | 83 +++++++++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 23 +++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index c7c252b6ac..2dc56c3367 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -778,5 +778,68 @@ describe('layoutDrawingBlock', () => { expect(fragment.width).toBe(600); expect(fragment.height).toBeCloseTo(333 * expectedScale, 10); // Allow floating point precision }); + + it('should center inline shapeGroup drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(200); + }); + + it('should right-align inline shapeGroup drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'right', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(400); + }); + + it('should not apply paragraph alignment metadata when shapeGroup is not inline', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Square' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + }); }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 0adc21465c..c43a30befc 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -85,6 +85,12 @@ export function layoutDrawingBlock({ const indentRight = typeof attrs?.hrIndentRight === 'number' ? attrs.hrIndentRight : 0; const maxWidthForBlock = attrs?.isFullWidth === true && maxWidth > 0 ? Math.max(1, maxWidth - indentLeft - indentRight) : maxWidth; + const rawWrap = attrs?.wrap as { type?: unknown } | undefined; + const isInlineShapeGroup = block.drawingKind === 'shapeGroup' && rawWrap?.type === 'Inline'; + const inlineParagraphAlignment = + attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right' + ? attrs.inlineParagraphAlignment + : undefined; if (width > maxWidthForBlock && maxWidthForBlock > 0) { const scale = maxWidthForBlock / width; @@ -107,12 +113,18 @@ export function layoutDrawingBlock({ } const pmRange = extractBlockPmRange(block); + let x = columnX(state.columnIndex) + marginLeft + indentLeft; + if (isInlineShapeGroup && inlineParagraphAlignment === 'center') { + x += Math.max(0, maxWidthForBlock - width) / 2; + } else if (isInlineShapeGroup && inlineParagraphAlignment === 'right') { + x += Math.max(0, maxWidthForBlock - width); + } const fragment: DrawingFragment = { kind: 'drawing', blockId: block.id, drawingKind: block.drawingKind, - x: columnX(state.columnIndex) + marginLeft + indentLeft, + x, y: state.cursorY + marginTop, width, height, 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 5b0681a31a..09a5a82086 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2144,6 +2144,89 @@ describe('paragraph converters', () => { expect(shapeGroupNodeToDrawingBlock).toHaveBeenCalledWith(shapeNode, nextBlockId, positions); }); + it('should attach inline paragraph alignment to inline shapeGroup drawings', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'center', + }, + resolvedParagraphProperties: {}, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('should not attach inline paragraph alignment to non-inline shapeGroup drawings', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'center', + }, + resolvedParagraphProperties: {}, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + attrs: { + wrap: { type: 'Square' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); + it('should handle shapeContainer node', () => { const shapeNode: PMNode = { type: 'shapeContainer' }; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 4d464027d1..9059382525 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -665,6 +665,26 @@ export function paragraphToFlowBlocks({ blockWithAttrs.attrs.anchorParagraphId = anchorParagraphId; return blockWithAttrs; }; + const attachInlineShapeGroupAlignment = (block: T): T => { + if (block.kind !== 'drawing') { + return block; + } + const drawingBlock = block as T & { + drawingKind?: string; + attrs?: Record; + }; + const rawWrap = drawingBlock.attrs?.wrap as { type?: unknown } | undefined; + if (drawingBlock.drawingKind !== 'shapeGroup' || rawWrap?.type !== 'Inline') { + return block; + } + if (!drawingBlock.attrs) { + drawingBlock.attrs = {}; + } + if (paragraphAttrs.alignment === 'center' || paragraphAttrs.alignment === 'right') { + drawingBlock.attrs.inlineParagraphAlignment = paragraphAttrs.alignment; + } + return block; + }; const flushParagraph = () => { if (currentRuns.length === 0) { @@ -757,11 +777,13 @@ export function paragraphToFlowBlocks({ const block = blockConverter(node, { ...blockOptions, blocks: newBlocks }); if (block) { attachAnchorParagraphId(block, anchorParagraphId); + attachInlineShapeGroupAlignment(block); blocks.push(block); } else if (newBlocks.length > 0) { // Some block converters may push multiple blocks to the provided array newBlocks.forEach((b) => { attachAnchorParagraphId(b, anchorParagraphId); + attachInlineShapeGroupAlignment(b); blocks.push(b); }); } @@ -779,6 +801,7 @@ export function paragraphToFlowBlocks({ const converter = SHAPE_CONVERTERS_REGISTRY[node.type]; const drawingBlock = converter(node, stableNextBlockId, positions); if (drawingBlock) { + attachInlineShapeGroupAlignment(drawingBlock); blocks.push(attachAnchorParagraphId(drawingBlock, anchorParagraphId)); } return; From a7791a7fd558891a12018df09a26efea42dc0d44 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 14 Apr 2026 12:50:57 -0700 Subject: [PATCH 2/4] fix(layout-engine): center inline shapeGroup within indented text box The centering math was using the full column width, ignoring paragraph indents (w:ind). This meant centered/right-aligned inline wpg groups in indented paragraphs were offset from where Word places them. Now propagates paragraphAttrs.indent through the drawing block attrs and subtracts it from the alignment box in layoutDrawingBlock. --- .../layout-engine/src/layout-drawing.test.ts | 70 +++++++++++++++++++ .../layout-engine/src/layout-drawing.ts | 10 +-- .../src/converters/paragraph.test.ts | 45 ++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 7 +- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index 2dc56c3367..a190b85ae5 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -841,5 +841,75 @@ describe('layoutDrawingBlock', () => { const fragment = state.page.fragments[0] as DrawingFragment; expect(fragment.x).toBe(0); }); + + it('should center within indented text box when paragraph has left indent', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + paragraphIndentLeft: 48, + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + // alignBox = 600 - 48 = 552, extra = 552 - 200 = 352, x = 0 + 48 + 176 = 224 + expect(fragment.x).toBe(224); + }); + + it('should center within indented text box when paragraph has left and right indent', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + paragraphIndentLeft: 48, + paragraphIndentRight: 48, + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + // alignBox = 600 - 48 - 48 = 504, extra = 504 - 200 = 304, x = 0 + 48 + 152 = 200 + expect(fragment.x).toBe(200); + }); + + it('should right-align within indented text box when paragraph has left indent', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'right', + paragraphIndentLeft: 96, + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + // alignBox = 600 - 96 = 504, extra = 504 - 200 = 304, x = 0 + 96 + 304 = 400 + expect(fragment.x).toBe(400); + }); }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index c43a30befc..12596f4da7 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -114,10 +114,12 @@ export function layoutDrawingBlock({ const pmRange = extractBlockPmRange(block); let x = columnX(state.columnIndex) + marginLeft + indentLeft; - if (isInlineShapeGroup && inlineParagraphAlignment === 'center') { - x += Math.max(0, maxWidthForBlock - width) / 2; - } else if (isInlineShapeGroup && inlineParagraphAlignment === 'right') { - x += Math.max(0, maxWidthForBlock - width); + if (isInlineShapeGroup && inlineParagraphAlignment) { + const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0; + const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0; + const alignBox = Math.max(0, maxWidthForBlock - pIndentLeft - pIndentRight); + const extra = Math.max(0, alignBox - width); + x += pIndentLeft + (inlineParagraphAlignment === 'center' ? extra / 2 : extra); } const fragment: DrawingFragment = { 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 09a5a82086..e6deaeba09 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2186,6 +2186,51 @@ describe('paragraph converters', () => { expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBe('center'); }); + it('should propagate paragraph indents to inline shapeGroup drawings', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'center', + indent: { left: 48, right: 24 }, + }, + resolvedParagraphProperties: {}, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBe('center'); + expect(drawingBlock?.attrs?.paragraphIndentLeft).toBe(48); + expect(drawingBlock?.attrs?.paragraphIndentRight).toBe(24); + }); + it('should not attach inline paragraph alignment to non-inline shapeGroup drawings', () => { const shapeNode: PMNode = { type: 'shapeGroup' }; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 9059382525..befede807e 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -677,11 +677,12 @@ export function paragraphToFlowBlocks({ if (drawingBlock.drawingKind !== 'shapeGroup' || rawWrap?.type !== 'Inline') { return block; } - if (!drawingBlock.attrs) { - drawingBlock.attrs = {}; - } if (paragraphAttrs.alignment === 'center' || paragraphAttrs.alignment === 'right') { + if (!drawingBlock.attrs) drawingBlock.attrs = {}; drawingBlock.attrs.inlineParagraphAlignment = paragraphAttrs.alignment; + const indent = paragraphAttrs.indent; + if (typeof indent?.left === 'number') drawingBlock.attrs.paragraphIndentLeft = indent.left; + if (typeof indent?.right === 'number') drawingBlock.attrs.paragraphIndentRight = indent.right; } return block; }; From 3a7e39a3ee0b0df776c7bcf4e420632f341d1010 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 14 Apr 2026 12:58:33 -0700 Subject: [PATCH 3/4] test(layout-engine): add missing alignment guard and edge case tests Covers all ST_Jc values (left/justify/distribute produce no offset), non-shapeGroup drawingKind guard (image/vectorShape/chart), missing and empty wrap guards, oversized group scaling interaction, right alignment propagation, and left/justify non-propagation in pm-adapter. --- .../layout-engine/src/layout-drawing.test.ts | 110 ++++++++++++++++++ .../src/converters/paragraph.test.ts | 86 ++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index a190b85ae5..939cd47c68 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -911,5 +911,115 @@ describe('layoutDrawingBlock', () => { // alignBox = 600 - 96 = 504, extra = 504 - 200 = 304, x = 0 + 96 + 304 = 400 expect(fragment.x).toBe(400); }); + + it('should not offset when alignment is left, justify, or distribute', () => { + for (const alignment of ['left', 'justify', 'distribute'] as const) { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: alignment, + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + } + }); + + it('should not offset non-shapeGroup drawings even with inline wrap and alignment', () => { + for (const drawingKind of ['image', 'vectorShape', 'chart'] as const) { + const context = createMockContext( + { + drawingKind, + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + } + }); + + it('should not offset shapeGroup when wrap is undefined', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + }); + + it('should not offset shapeGroup when wrap has no type', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: {}, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + }); + + it('should not shift oversized centered shapeGroup after width scaling', () => { + const context = createMockContext( + { + drawingKind: 'shapeGroup', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 800, height: 600 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + // Scaled to maxWidthForBlock (600), no slack left, x = 0 + expect(fragment.width).toBe(600); + expect(fragment.x).toBe(0); + }); }); }); 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 e6deaeba09..e3501eb82a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2272,6 +2272,92 @@ describe('paragraph converters', () => { expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBeUndefined(); }); + it('should attach right alignment to inline shapeGroup drawings', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'right', + }, + resolvedParagraphProperties: {}, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBe('right'); + }); + + it('should not attach alignment for left or justify paragraphs', () => { + for (const alignment of ['left', 'justify'] as const) { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment, + }, + resolvedParagraphProperties: {}, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBeUndefined(); + } + }); + it('should handle shapeContainer node', () => { const shapeNode: PMNode = { type: 'shapeContainer' }; From e955f8275753edabd80d77cfbcd4e465d2d0d72b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 14 Apr 2026 13:23:15 -0700 Subject: [PATCH 4/4] fix(pm-adapter): treat w:jc=distribute as center for inline shapeGroups Word distributes remaining space equally around single inline content, which visually centers a sole inline drawing. normalizeAlignment collapses 'distribute' to 'justify', so we check the raw justification value from resolvedParagraphProperties to distinguish it from 'both' (which only stretches inter-word spacing and does not center). --- .../layout-engine/src/layout-drawing.test.ts | 4 +- .../src/converters/paragraph.test.ts | 88 +++++++++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 10 ++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index 939cd47c68..27a5f7f01e 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -912,8 +912,8 @@ describe('layoutDrawingBlock', () => { expect(fragment.x).toBe(400); }); - it('should not offset when alignment is left, justify, or distribute', () => { - for (const alignment of ['left', 'justify', 'distribute'] as const) { + it('should not offset when alignment is left or justify', () => { + for (const alignment of ['left', 'justify'] as const) { const context = createMockContext( { drawingKind: 'shapeGroup', 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 e3501eb82a..c701136ccd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2358,6 +2358,94 @@ describe('paragraph converters', () => { } }); + it('should treat distribute as center for inline shapeGroup drawings', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'justify', + }, + resolvedParagraphProperties: { + justification: 'distribute', + }, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('should not treat both/justify as center (only distribute)', () => { + const shapeNode: PMNode = { type: 'shapeGroup' }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + alignment: 'justify', + }, + resolvedParagraphProperties: { + justification: 'both', + }, + } as never); + + vi.mocked(shapeGroupNodeToDrawingBlock).mockReturnValue({ + kind: 'drawing', + id: 'drawing-0', + drawingKind: 'shapeGroup', + wrap: { type: 'Inline' }, + attrs: { + wrap: { type: 'Inline' }, + }, + shapes: [], + } as never); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [shapeNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + ); + + const drawingBlock = blocks.find((block) => block.kind === 'drawing') as FlowBlock & { + attrs?: Record; + }; + expect(drawingBlock?.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); + it('should handle shapeContainer node', () => { const shapeNode: PMNode = { type: 'shapeContainer' }; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index befede807e..0bc5a4d59b 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -677,9 +677,15 @@ export function paragraphToFlowBlocks({ if (drawingBlock.drawingKind !== 'shapeGroup' || rawWrap?.type !== 'Inline') { return block; } - if (paragraphAttrs.alignment === 'center' || paragraphAttrs.alignment === 'right') { + // w:jc="distribute" distributes remaining space equally around inline content, + // which visually centers a sole inline drawing. normalizeAlignment collapses + // 'distribute' to 'justify', so we check the raw justification value to distinguish + // it from 'both' (which only stretches inter-word spacing and does not center). + const isDistribute = resolvedParagraphProperties.justification === 'distribute'; + const effectiveAlignment = isDistribute ? 'center' : paragraphAttrs.alignment; + if (effectiveAlignment === 'center' || effectiveAlignment === 'right') { if (!drawingBlock.attrs) drawingBlock.attrs = {}; - drawingBlock.attrs.inlineParagraphAlignment = paragraphAttrs.alignment; + drawingBlock.attrs.inlineParagraphAlignment = effectiveAlignment; const indent = paragraphAttrs.indent; if (typeof indent?.left === 'number') drawingBlock.attrs.paragraphIndentLeft = indent.left; if (typeof indent?.right === 'number') drawingBlock.attrs.paragraphIndentRight = indent.right;