diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index be0b316ce6..f22d14296e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -806,6 +806,22 @@ export type TextPart = { isLineBreak?: boolean; /** Indicates this line break follows an empty paragraph (creates extra spacing). */ isEmptyParagraph?: boolean; + /** + * SD-2804: ECMA-376 §20.4.2.38 lets a textbox hold full body-level + * content, including paragraphs whose runs carry inline w:drawing + * images. When the importer encounters such a drawing it appends a + * part with `kind: 'image'` carrying the raw media path; pm-adapter's + * hydrateImageBlocks resolves it to a data URI alongside ImageRuns so + * binary (Y.js) and string (zip) media files share the same path + * candidates and Uint8Array decoding. + */ + kind?: 'image'; + src?: string; + extension?: string; + rId?: string; + width?: number; + height?: number; + alt?: string; }; /** Text content configuration for shapes. */ diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 3bd988f6ab..407565773a 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4458,6 +4458,21 @@ export class DomPainter { if (part.isEmptyParagraph) { currentParagraph.style.minHeight = '1em'; } + } else if (part.kind === 'image' && part.src) { + // SD-2804: image part produced by the textbox importer for an + // inline w:drawing inside a textbox run. Render as alongside + // sibling text spans so layout matches Word's inline flow. Match + // body inline images' baseline default (`vertical-align: bottom`) + // so an image and adjacent text line up the same way inside a + // textbox as outside. + const img = this.doc!.createElement('img'); + img.src = part.src; + img.alt = part.alt ?? ''; + if (typeof part.width === 'number') img.style.width = `${part.width}px`; + if (typeof part.height === 'number') img.style.height = `${part.height}px`; + img.style.display = 'inline-block'; + img.style.verticalAlign = 'bottom'; + currentParagraph.appendChild(img); } else { const span = this.doc!.createElement('span'); span.textContent = this.resolveShapeTextPartText(part, context); diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index d09d3245ad..4a3bf636d7 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -15,6 +15,8 @@ import type { ShapeGroupDrawing, ShapeGroupImageChild, ShapeGroupTransform, + TextPart, + VectorShapeDrawing, FlowBlock, ImageRun, ParagraphBlock, @@ -1174,6 +1176,37 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { + 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) { + const vectorShapeBlock = drawingBlock as VectorShapeDrawing; + return { + ...vectorShapeBlock, + textContent: { ...vectorShapeBlock.textContent, parts: hydratedParts }, + }; + } + return blk; + } + if (drawingBlock.drawingKind !== 'shapeGroup') { return blk; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 71b03b717f..1079a5b816 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -1061,6 +1061,36 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) { } else if (el.name === 'sd:totalPageNumber') { hasText = true; appendFieldPart('NUMPAGES', el, paragraphProperties); + } else if (el.name === 'w:drawing') { + // SD-2804 / ECMA-376 §20.4.2.38: a textbox can hold body-level + // content, including runs with inline w:drawing images. Defer to + // the existing v3 wp drawing handler for rId → src + size resolution + // so this branch behaves identically to body inline images. Anchored + // drawings inside textboxes are out of scope (the wrap / position / + // transform metadata isn't carried into the text-parts model); + // confine support to wp:inline. + const inline = el.elements?.find((child) => child?.name === 'wp:inline'); + if (inline) { + const imagePm = handleImageNode(inline, { ...params, nodes: [el] }, false); + // Skip hidden drawings (wp:docPr hidden="1") to match the body-level + // pipeline — handleImageNode flags them via attrs.hidden, and image + // parts bypass the top-level filtering that drops them elsewhere. + if (imagePm?.attrs?.src && imagePm.attrs.hidden !== true) { + hasText = true; + const sizeAttr = imagePm.attrs.size || imagePm.attrs; + textParts.push({ + text: '', + formatting, + kind: 'image', + src: imagePm.attrs.src, + extension: imagePm.attrs.extension, + rId: imagePm.attrs.rId, + width: typeof sizeAttr?.width === 'number' ? sizeAttr.width : undefined, + height: typeof sizeAttr?.height === 'number' ? sizeAttr.height : undefined, + alt: imagePm.attrs.alt || '', + }); + } + } } }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index cf17256fc5..d704216d24 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -2023,4 +2023,119 @@ describe('getVectorShape', () => { expect(result1.attrs.src).not.toBe(result2.attrs.src); }); }); + + // SD-2804: ECMA-376 §20.4.2.38 — a textbox (CT_TxbxContent) can hold rich + // body-level content, including paragraphs whose runs carry inline images + // via w:drawing > wp:inline > pic:pic. The text-only extractor used to + // silently skip those drawings, leaving the textbox visually empty even + // though export round-tripped the image. The fix surfaces the image as a + // textContent part with kind='image' so the shape painter can render it. + describe('SD-2804: image inside textbox content', () => { + const docxFixture = { + 'word/_rels/header1.xml.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { Id: 'rId1', Target: 'media/image1.png' }, + }, + ], + }, + ], + }, + }; + + const makeShape = () => ({ + elements: [ + { + name: 'wps:wsp', + elements: [ + { name: 'wps:cNvSpPr', attributes: { txBox: '1' } }, + { + name: 'wps:spPr', + elements: [ + { name: 'a:prstGeom', attributes: { prst: 'rect' } }, + { name: 'a:xfrm', elements: [{ name: 'a:ext', attributes: { cx: '4745620', cy: '520860' } }] }, + ], + }, + { + name: 'wps:txbx', + elements: [ + { + name: 'w:txbxContent', + elements: [ + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:noProof' }] }, + { + name: 'w:drawing', + elements: [ + { + name: 'wp:inline', + elements: [ + { name: 'wp:extent', attributes: { cx: '481330', cy: '422910' } }, + { name: 'wp:docPr', attributes: { id: '1', name: 'Picture 2' } }, + { + name: 'a:graphic', + elements: [ + { + name: 'a:graphicData', + attributes: { + uri: 'http://schemas.openxmlformats.org/drawingml/2006/picture', + }, + elements: [ + { + name: 'pic:pic', + elements: [ + { + name: 'pic:blipFill', + elements: [{ name: 'a:blip', attributes: { 'r:embed': 'rId1' } }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { name: 'wps:bodyPr', attributes: {} }, + ], + }, + ], + }); + + it('emits an image part in textContent for an inline w:drawing inside the textbox', () => { + const graphicData = makeShape(); + const result = getVectorShape({ + params: { nodes: [{ name: 'w:drawing', elements: [] }], docx: docxFixture, filename: 'header1.xml' }, + node: { name: 'wp:anchor', elements: [] }, + graphicData, + size: { width: 374, height: 41 }, + }); + + expect(result?.type).toBe('vectorShape'); + const parts = result?.attrs?.textContent?.parts || []; + const imagePart = parts.find((p) => p.kind === 'image'); + expect(imagePart).toBeTruthy(); + expect(typeof imagePart?.src).toBe('string'); + expect(imagePart?.src.length).toBeGreaterThan(0); + }); + }); });