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..27a5f7f01e 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,248 @@ 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); + }); + + 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); + }); + + it('should not offset when alignment is left or justify', () => { + for (const alignment of ['left', 'justify'] 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/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 0adc21465c..12596f4da7 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,20 @@ export function layoutDrawingBlock({ } const pmRange = extractBlockPmRange(block); + let x = columnX(state.columnIndex) + marginLeft + indentLeft; + 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 = { 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..c701136ccd 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,308 @@ 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 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' }; + + 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 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 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 4d464027d1..0bc5a4d59b 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -665,6 +665,33 @@ 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; + } + // 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 = 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; + } + return block; + }; const flushParagraph = () => { if (currentRuns.length === 0) { @@ -757,11 +784,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 +808,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;