diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index cd4bcdd1c6..a846dec470 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -349,6 +349,38 @@ const getRunWidth = (run: Run): number => { return typeof width === 'number' ? width : 0; }; +const isAtomicLayoutRun = (run: Run): boolean => 'src' in run || run.kind === 'math' || run.kind === 'fieldAnnotation'; + +const getAtomicRunLayoutWidth = (run: Run): number => { + const margins = run as { distLeft?: number; distRight?: number }; + return getRunWidth(run) + (margins.distLeft ?? 0) + (margins.distRight ?? 0); +}; + +const getAtomicRunLayoutHeight = (run: Run): number => { + const baseHeight = typeof (run as { height?: number }).height === 'number' ? (run as { height: number }).height : 0; + const margins = run as { distTop?: number; distBottom?: number }; + return baseHeight + (margins.distTop ?? 0) + (margins.distBottom ?? 0); +}; + +/** Max atomic (image/math/field) height for runs actually included on [fromRun, toRun]. */ +const getLineMaxAtomicHeight = ( + runs: Run[], + fromRun: number, + fromChar: number, + toRun: number, + toChar: number, +): number => { + let max = 0; + for (let r = fromRun; r <= toRun; r += 1) { + const run = runs[r]; + if (!isAtomicLayoutRun(run)) continue; + if (r === toRun && toChar === 0) continue; + if (r === fromRun && r === toRun && toChar <= fromChar) continue; + max = Math.max(max, getAtomicRunLayoutHeight(run)); + } + return max; +}; + /** * Checks if a break run is a line break (as opposed to page/column break). * @@ -1395,6 +1427,17 @@ export function remeasureParagraph( endChar = text.length > 0 ? text.length : start + 1; continue; } + if (text.length === 0 && isAtomicLayoutRun(run)) { + const atomicWidth = getAtomicRunLayoutWidth(run); + if (width > 0 && width + atomicWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) { + didBreakInThisLine = true; + break; + } + width += atomicWidth; + endRun = r; + endChar = 1; + continue; + } for (let c = start; c < text.length; c += 1) { const ch = text[c]; if (ch === '\t') { @@ -1488,6 +1531,8 @@ export function remeasureParagraph( endChar = startChar + 1; } + const lineMaxAtomicHeight = getLineMaxAtomicHeight(runs, startRun, startChar, endRun, endChar); + const line: Line = { fromRun: startRun, fromChar: startChar, @@ -1496,8 +1541,9 @@ export function remeasureParagraph( width, ascent: 0, descent: 0, - lineHeight: lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), + lineHeight: Math.max(lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), lineMaxAtomicHeight), maxWidth: effectiveMaxWidth, + ...(lineMaxAtomicHeight > 0 ? { maxImageHeight: lineMaxAtomicHeight } : {}), }; lines.push(line); if (lineMaxTextFontSize > 0) { diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 6f6db14a38..ae8acee7f3 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -1638,4 +1638,53 @@ describe('remeasureParagraph', () => { expect(measure.lines[0].width).toBeGreaterThan(0); }); }); + + describe('atomic runs', () => { + it('does not retain image line height after rewinding past an inline image break point', () => { + const block = createBlock([ + textRun('A '), + { + kind: 'image', + src: 'data:image/png;base64,abc', + width: 179, + height: 179, + pmStart: 3, + pmEnd: 4, + }, + textRun('B'.repeat(30)), + ]); + + // Fits "A " + image, then overflows on following text and rewinds to the space. + const measure = remeasureParagraph(block, 200); + + expect(measure.lines.length).toBeGreaterThan(1); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[0].toChar).toBe(2); + expect(measure.lines[0].maxImageHeight).toBeUndefined(); + expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2); + expect(measure.lines[1].maxImageHeight).toBe(179); + }); + + it('measures image-only paragraphs with correct width and line height', () => { + const block = createBlock([ + { + kind: 'image', + src: 'data:image/png;base64,abc', + width: 179, + height: 179, + pmStart: 1, + pmEnd: 2, + }, + ]); + block.attrs = { alignment: 'center' }; + + const measure = remeasureParagraph(block, 179.8); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(179); + expect(measure.lines[0].lineHeight).toBe(179); + expect(measure.lines[0].maxImageHeight).toBe(179); + expect(measure.totalHeight).toBe(179); + }); + }); }); 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 e86f7d784f..701ce2ae8e 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -842,6 +842,70 @@ describe('layoutDrawingBlock', () => { expect(fragment.x).toBe(400); }); + it('should center inline textboxShape drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + 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; + // alignBox = 600, extra = 600 - 200 = 400, x = 0 + 200 = 200 + expect(fragment.x).toBe(200); + }); + + it('should right-align inline textboxShape drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + 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 textboxShape is not inline', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + 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 not apply paragraph alignment metadata when shapeGroup is not inline', () => { const context = createMockContext( { diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 67ffa21cb2..52543db152 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -89,7 +89,10 @@ export function layoutDrawingBlock({ 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'; + // Inline shape groups and textboxes render at their authored width, so a centered or + // right-aligned host paragraph must offset the whole box within the column (SD: IT-1140). + const isInlineAlignableDrawing = + (block.drawingKind === 'shapeGroup' || block.drawingKind === 'textboxShape') && rawWrap?.type === 'Inline'; const inlineParagraphAlignment = attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right' ? attrs.inlineParagraphAlignment @@ -117,7 +120,7 @@ export function layoutDrawingBlock({ const pmRange = extractBlockPmRange(block); let x = columnX(state) + marginLeft + indentLeft; - if (isInlineShapeGroup && inlineParagraphAlignment) { + if (isInlineAlignableDrawing && 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); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts index 4f4bde372d..65aeab0855 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts @@ -778,6 +778,116 @@ describe('shapes converter', () => { }, }); }); + + it('derives inlineParagraphAlignment from a centered wrapper paragraph for inline textboxes', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + textAlign: 'center', + paragraphProperties: { justification: 'center' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('derives wrapper paragraph indents with inlineParagraphAlignment for centered inline textboxes', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + textAlign: 'center', + paragraphProperties: { + justification: 'center', + indent: { left: 720, right: 360 }, + }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + expect(result.attrs?.paragraphIndentLeft).toBe(48); + expect(result.attrs?.paragraphIndentRight).toBe(24); + }); + + it('derives inlineParagraphAlignment "center" from a distribute wrapper paragraph', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + textAlign: 'justify', + paragraphProperties: { justification: 'distribute' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('does not derive inlineParagraphAlignment for non-inline (anchored) textboxes', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Square' }, + wrapperParagraph: { + textAlign: 'center', + paragraphProperties: { justification: 'center' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); + + it('does not derive inlineParagraphAlignment for left-aligned wrapper paragraphs', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + textAlign: 'left', + paragraphProperties: { justification: 'left' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); }); describe('shapeTextboxNodeToDrawingBlock', () => { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts index 348778b9a8..47d4c950f2 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts @@ -47,6 +47,7 @@ import { mergeWrapDistancesFromPadding, ptToPx, } from '../utilities.js'; +import { computeParagraphAttrs } from '../attributes/index.js'; import { getLastParagraphFont } from './paragraph.js'; // ============================================================================ @@ -837,6 +838,41 @@ export function shapeGroupNodeToDrawingBlock( }); } +/** + * Inline textbox/shape paragraphs whose only content is the drawing are lifted to the + * document top level by the importer, which stashes the original host paragraph props on + * `wrapperParagraph`. Those drawings keep their authored width, so a centered/right-aligned + * host paragraph must still offset the whole box on the page. Derive the alignment metadata + * the layout engine consumes (`inlineParagraphAlignment` plus wrapper indents) from that + * wrapper (SD: IT-1140). + * + * Only applies to inline-wrapped drawings; anchored drawings are positioned by their anchor. + */ +const resolveInlineAlignmentFromWrapper = (rawAttrs: Record): Record => { + const wrapType = (rawAttrs.wrap as { type?: unknown } | undefined)?.type; + if (wrapType !== 'Inline' || !isPlainObject(rawAttrs.wrapperParagraph)) { + return {}; + } + const wrapper = rawAttrs.wrapperParagraph as Record; + const paragraphProperties = isPlainObject(wrapper.paragraphProperties) + ? (wrapper.paragraphProperties as Record) + : undefined; + // w:jc="distribute" visually centers a sole inline drawing; the PM `textAlign` attribute + // collapses it to 'justify', so fall back to the raw justification to detect it. + const justification = paragraphProperties?.justification; + const textAlign = typeof wrapper.textAlign === 'string' ? wrapper.textAlign : undefined; + const effectiveAlignment = justification === 'distribute' ? 'center' : textAlign; + if (effectiveAlignment !== 'center' && effectiveAlignment !== 'right') { + return {}; + } + const metadata: Record = { inlineParagraphAlignment: effectiveAlignment }; + const { paragraphAttrs } = computeParagraphAttrs({ type: 'paragraph', attrs: wrapper } as PMNode); + const indent = paragraphAttrs.indent; + if (typeof indent?.left === 'number') metadata.paragraphIndentLeft = indent.left; + if (typeof indent?.right === 'number') metadata.paragraphIndentRight = indent.right; + return metadata; +}; + /** * Convert a ProseMirror shapeContainer node to a DrawingBlock * @@ -869,6 +905,7 @@ export function shapeContainerNodeToDrawingBlock( return buildDrawingBlock( { ...rawAttrs, + ...resolveInlineAlignmentFromWrapper(rawAttrs), ...(textContent ? { textContent } : {}), ...(rawAttrs.textAlign == null && textContent?.horizontalAlign ? { textAlign: textContent.horizontalAlign } : {}), ...(rawAttrs.textInsets == null ? { textInsets: resolveTextboxInsetsFromAttrs(textboxAttrs) } : {}), diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts index 2d19dade97..a9f1dbfff0 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts @@ -904,6 +904,39 @@ describe('Media Utilities', () => { }); }); + describe('hydrateImageBlocks - textboxShape contentBlocks hydration', () => { + it('hydrates ImageRuns inside textboxShape contentBlocks', () => { + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + id: 'textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ kind: 'image', src: 'word/media/image1.png', width: 100, height: 100 }], + }, + ], + } as unknown as FlowBlock, + ]; + const mediaFiles = { 'word/media/image1.png': 'base64ImageData' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + const drawingBlock = result[0] as { + contentBlocks: Array<{ runs: Array<{ kind?: string; src?: string }> }>; + }; + + expect(drawingBlock.contentBlocks[0].runs[0]).toEqual({ + kind: 'image', + src: 'data:image/png;base64,base64ImageData', + width: 100, + height: 100, + }); + }); + }); + describe('hydrateImageBlocks - ShapeGroup image hydration', () => { it('hydrates image children inside shapeGroup drawing blocks', () => { const blocks: FlowBlock[] = [ diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts index 4d8e61daca..524789cd0c 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts @@ -17,6 +17,7 @@ import type { ShapeGroupTransform, ShapeEffects, TextPart, + TextboxDrawing, VectorShapeDrawing, FlowBlock, ImageRun, @@ -1276,28 +1277,55 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { - if (part?.kind !== 'image' || !part.src || part.src.startsWith('data:')) { - return part; + let blockChanged = false; + let nextBlock: VectorShapeDrawing | TextboxDrawing = drawingBlock; + + if (drawingBlock.drawingKind === 'textboxShape') { + const contentBlocks = drawingBlock.contentBlocks; + if (Array.isArray(contentBlocks) && contentBlocks.length > 0) { + let contentBlocksChanged = false; + const hydratedContentBlocks = contentBlocks.map((paragraphBlock) => { + if (paragraphBlock.kind !== 'paragraph' || !paragraphBlock.runs?.length) { + return paragraphBlock; + } + const hydratedRuns = hydrateRuns(paragraphBlock.runs); + if (hydratedRuns !== paragraphBlock.runs) { + contentBlocksChanged = true; + return { ...paragraphBlock, runs: hydratedRuns }; + } + return paragraphBlock; + }); + if (contentBlocksChanged) { + blockChanged = true; + nextBlock = { ...drawingBlock, contentBlocks: hydratedContentBlocks }; + } } - const resolvedSrc = resolveImageSrc(part.src, part.rId, undefined, part.extension); - if (resolvedSrc) { - partsChanged = true; - return { ...part, src: resolvedSrc }; + } + + const parts = nextBlock.textContent?.parts; + if (parts && parts.length > 0) { + let partsChanged = false; + const hydratedParts = parts.map((part: TextPart) => { + if (part?.kind !== 'image' || !part.src || part.src.startsWith('data:')) { + return part; + } + const resolvedSrc = resolveImageSrc(part.src, part.rId, undefined, part.extension); + if (resolvedSrc) { + partsChanged = true; + return { ...part, src: resolvedSrc }; + } + return part; + }); + if (partsChanged) { + blockChanged = true; + nextBlock = { + ...nextBlock, + textContent: { ...nextBlock.textContent, parts: hydratedParts }, + }; } - return part; - }); - if (partsChanged) { - const vectorShapeBlock = drawingBlock as VectorShapeDrawing; - return { - ...vectorShapeBlock, - textContent: { ...vectorShapeBlock.textContent, parts: hydratedParts }, - }; } - return blk; + + return blockChanged ? nextBlock : blk; } if (drawingBlock.drawingKind !== 'shapeGroup') { diff --git a/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js b/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js index 7ef3d1ed4a..aee93ab835 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js @@ -73,6 +73,15 @@ export const ShapeContainer = Node.create({ rendered: false, }, + // Host-paragraph properties preserved when an inline drawing is the sole + // block content of a and gets hoisted to the top level. Without this + // the paragraph's alignment (e.g. centered) is lost and the layout adapter + // cannot center the drawing. See legacy-handle-paragraph-node.js. + wrapperParagraph: { + default: null, + rendered: false, + }, + anchorData: { rendered: false, },