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
12 changes: 12 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,18 @@ 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 a resolved data-URI src so the
* shape painter can render an <img> alongside the text.
*/
kind?: 'image';
src?: string;
width?: number;
height?: number;
alt?: string;
};

/** Text content configuration for shapes. */
Expand Down
12 changes: 12 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,18 @@ 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.
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 = 'middle';
currentParagraph.appendChild(img);
} else {
const span = this.doc!.createElement('span');
span.textContent = this.resolveShapeTextPartText(part, context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,29 @@ const handleChartDrawing = (params, node, graphicData, size, padding, marginOffs
* wrap: string
* }|null} Text content with formatting information and line break markers, or null if no text found
*/
// SD-2804: turn a path-style src ("word/media/image1.png" or "media/image1.png")
// into a data URI by reading converter.media. Mirrors how layout-engine's
// hydrateRuns resolves body ImageRuns, but inline since the text-parts model
// the shape painter consumes has no downstream hydration step.
function resolveImagePartSrc(src, params, extension) {
if (!src || src.startsWith('data:')) return src;
const media = params?.converter?.media;
if (!media) return src;
const candidates = [src];
if (src.startsWith('word/')) candidates.push(src.slice(5));
else candidates.push(`word/${src}`);
for (const candidate of candidates) {
const data = media[candidate];
if (!data) continue;
if (typeof data === 'string') {
if (data.startsWith('data:')) return data;
const ext = extension || (src.includes('.') ? src.slice(src.lastIndexOf('.') + 1) : 'png');
return `data:image/${ext};base64,${data}`;
}
}
return src;
}

function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) {
if (!textBoxContent || !textBoxContent.elements) return null;

Expand Down Expand Up @@ -1061,6 +1084,32 @@ 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. Reuse the
// existing v3 wp drawing handler so the rId → resolution matches
// what body paragraphs use, then upgrade the path-style src to a
// data URI from converter.media (the text-parts model has no
// downstream hydration step like body ImageRuns do).
const inlineOrAnchor = el.elements?.find((child) => child?.name === 'wp:inline' || child?.name === 'wp:anchor');
if (inlineOrAnchor) {
const isAnchor = inlineOrAnchor.name === 'wp:anchor';
const imagePm = handleImageNode(inlineOrAnchor, { ...params, nodes: [el] }, isAnchor);
if (imagePm?.attrs?.src) {
hasText = true;
const sizeAttr = imagePm.attrs.size || imagePm.attrs;
const resolvedSrc = resolveImagePartSrc(imagePm.attrs.src, params, imagePm.attrs.extension);
textParts.push({
text: '',
formatting,
kind: 'image',
src: resolvedSrc,
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