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);
+ });
+ });
});