Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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);
Expand Down
33 changes: 33 additions & 0 deletions packages/layout-engine/pm-adapter/src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
ShapeGroupDrawing,
ShapeGroupImageChild,
ShapeGroupTransform,
TextPart,
VectorShapeDrawing,
FlowBlock,
ImageRun,
ParagraphBlock,
Expand Down Expand Up @@ -1174,6 +1176,37 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record<stri
// Handle DrawingBlocks with shapeGroup kind (contain image children)
if (blk.kind === 'drawing') {
const drawingBlock = blk as DrawingBlock;

// SD-2804: vectorShape blocks carry inline image parts in their
// textContent.parts array (importer stamps part.kind === 'image'
// for w:drawing inside textbox runs). Hydrate the same way as
// ImageRuns so Uint8Array (Y.js binary) and string (zip) media
// alike resolve to a data URI.
if (drawingBlock.drawingKind === 'vectorShape') {
const parts = (drawingBlock as VectorShapeDrawing).textContent?.parts;
if (!parts || parts.length === 0) return blk;
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) {
const vectorShapeBlock = drawingBlock as VectorShapeDrawing;
return {
...vectorShapeBlock,
textContent: { ...vectorShapeBlock.textContent, parts: hydratedParts },
};
}
return blk;
}

if (drawingBlock.drawingKind !== 'shapeGroup') {
return blk;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
});
}
}
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading