Skip to content

Commit 862f3fe

Browse files
chittolinagchittolinacaio-pizzol
authored
SD-2279 - fix: floating textbox not rendering (#2732)
* fix: floating textbox not rendering * refactor: match decorative and graphic elements by local name Migrates two remaining hardcoded namespace prefix lookups in the DrawingML import path so docs that re-prefix the DrawingML namespace (e.g. ns6: instead of a:) get consistent treatment alongside the rest of the PR. - encode-image-node-helpers.js: `adec:decorative`/`a16:decorative` in wp:docPr extLst now matches by local name `decorative`. - merge-drawing-children.js: `a:graphic`/`a:graphicData` in the zero-id repair path now use findChildByLocalName. * test: cover re-prefixed decorative and graphic lookups Adds regression tests for the namespace-prefix-agnostic lookups introduced in the previous commit: - encode-image-node-helpers: covers `adec:decorative`, `a16:decorative`, re-aliased `ns7:decorative`, val=0, and missing-decorative cases. - merge-drawing-children: covers the zero-id repair path when the graphic subtree uses `ns6:graphic` / `ns6:graphicData`. Both new "re-prefixed" tests fail against the pre-fix implementation and pass after, locking in the local-name matching as the contract. * test: cover vector-shape-helpers re-prefixed namespace path Adds a 'namespace prefix tolerance' describe block to lock in the local-name matching introduced in this PR for vector-shape-helpers.js (34 callsites converted from `el.name === 'a:foo'` to findChild / hasLocalName / filterChildren / getLocalName). Covers the public surface that depends on those helpers: - extractStrokeWidth (a:ln) - extractStrokeColor (a:ln/a:noFill/a:solidFill/a:srgbClr/a:schemeClr) - extractFillColor (a:solidFill/a:gradFill stops/lin angle) - extractLineEnds (a:ln/a:headEnd/a:tailEnd) - extractCustomGeometry (a:custGeom/a:pathLst/a:path with a:moveTo/a:lnTo/a:close, plus a:pt children) 6 of the 7 new tests fail against the pre-fix implementation and pass after, locking in the local-name matching as the contract. --------- Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent a5e4a55 commit 862f3fe

7 files changed

Lines changed: 486 additions & 77 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Utilities for working with DrawingML nodes whose namespace prefixes may vary (e.g. `a:` vs `ns6:`).
3+
*/
4+
5+
/**
6+
* Extract the local name from a qualified XML node name.
7+
* @param {string|undefined|null} name
8+
* @returns {string}
9+
*/
10+
export const getLocalName = (name) => {
11+
if (typeof name !== 'string') return '';
12+
const parts = name.split(':');
13+
return parts.length ? parts[parts.length - 1] : name;
14+
};
15+
16+
/**
17+
* Check if a node has the requested local name, ignoring namespace prefix.
18+
* @param {Object|undefined|null} node
19+
* @param {string} localName
20+
* @returns {boolean}
21+
*/
22+
export const hasLocalName = (node, localName) => {
23+
if (!node || typeof node !== 'object') return false;
24+
return getLocalName(node.name) === localName;
25+
};
26+
27+
/**
28+
* Find the first child element with the requested local name.
29+
* @param {Array<Object>|undefined|null} elements
30+
* @param {string} localName
31+
* @returns {Object|undefined}
32+
*/
33+
export const findChildByLocalName = (elements, localName) => {
34+
if (!Array.isArray(elements)) return undefined;
35+
return elements.find((el) => hasLocalName(el, localName));
36+
};
37+
38+
/**
39+
* Filter child elements by local name.
40+
* @param {Array<Object>|undefined|null} elements
41+
* @param {string} localName
42+
* @returns {Array<Object>}
43+
*/
44+
export const filterChildrenByLocalName = (elements, localName) => {
45+
if (!Array.isArray(elements)) return [];
46+
return elements.filter((el) => hasLocalName(el, localName));
47+
};
48+
49+
/**
50+
* Returns true when any child element has the requested local name.
51+
* @param {Array<Object>|undefined|null} elements
52+
* @param {string} localName
53+
* @returns {boolean}
54+
*/
55+
export const someChildHasLocalName = (elements, localName) => {
56+
if (!Array.isArray(elements)) return false;
57+
return elements.some((el) => hasLocalName(el, localName));
58+
};

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from './textbox-content-helpers.js';
2121
import { parseRelativeHeight } from './relative-height.js';
2222
import { CHART_URI, resolveChartPart, parseChartXml } from './chart-helpers.js';
23+
import { findChildByLocalName, someChildHasLocalName, hasLocalName } from './drawingml-utils.js';
2324

2425
const DRAWING_XML_TAG = 'w:drawing';
2526
const SHAPE_URI = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
@@ -275,8 +276,8 @@ export function handleImageNode(node, params, isAnchor) {
275276
};
276277
}
277278

278-
const graphic = node.elements.find((el) => el.name === 'a:graphic');
279-
const graphicData = graphic?.elements.find((el) => el.name === 'a:graphicData');
279+
const graphic = findChildByLocalName(node.elements, 'graphic');
280+
const graphicData = findChildByLocalName(graphic?.elements, 'graphicData');
280281
const { uri } = graphicData?.attributes || {};
281282
if (!graphicData) {
282283
return null;
@@ -321,14 +322,14 @@ export function handleImageNode(node, params, isAnchor) {
321322
}
322323

323324
const blipFill = picture.elements.find((el) => el.name === 'pic:blipFill');
324-
const blip = blipFill?.elements.find((el) => el.name === 'a:blip');
325+
const blip = findChildByLocalName(blipFill?.elements, 'blip');
325326
if (!blip) {
326327
return null;
327328
}
328329

329330
// Check for image effects (grayscale, luminance, etc.)
330-
const hasGrayscale = blip.elements?.some((el) => el.name === 'a:grayscl');
331-
const lumEl = blip.elements?.find((el) => el.name === 'a:lum');
331+
const hasGrayscale = someChildHasLocalName(blip.elements, 'grayscl');
332+
const lumEl = findChildByLocalName(blip.elements, 'lum');
332333
const rawBright = Number(lumEl?.attributes?.bright);
333334
const rawContrast = Number(lumEl?.attributes?.contrast);
334335
const lum =
@@ -349,9 +350,9 @@ export function handleImageNode(node, params, isAnchor) {
349350
//
350351
// Skip cover mode when srcRect already emitted explicit clipping or when srcRect has
351352
// negative values (Word already adjusted the mapping).
352-
const stretch = blipFill?.elements?.find((el) => el.name === 'a:stretch');
353-
const fillRect = stretch?.elements?.find((el) => el.name === 'a:fillRect');
354-
const srcRect = blipFill?.elements?.find((el) => el.name === 'a:srcRect');
353+
const stretch = findChildByLocalName(blipFill?.elements, 'stretch');
354+
const fillRect = findChildByLocalName(stretch?.elements, 'fillRect');
355+
const srcRect = findChildByLocalName(blipFill?.elements, 'srcRect');
355356
const srcRectAttrs = srcRect?.attributes || {};
356357
const clipPath = buildClipPathFromSrcRect(srcRectAttrs);
357358

@@ -370,7 +371,7 @@ export function handleImageNode(node, params, isAnchor) {
370371

371372
const spPr = picture.elements.find((el) => el.name === 'pic:spPr');
372373
if (spPr) {
373-
const xfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
374+
const xfrm = findChildByLocalName(spPr.elements, 'xfrm');
374375
if (xfrm?.attributes) {
375376
transformData = {
376377
...transformData,
@@ -384,7 +385,7 @@ export function handleImageNode(node, params, isAnchor) {
384385
// --- Parse pic:nvPicPr for lockAspectRatio, hyperlink ---
385386
const nvPicPr = picture.elements.find((el) => el.name === 'pic:nvPicPr');
386387
const cNvPicPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPicPr');
387-
const picLocks = cNvPicPr?.elements?.find((el) => el.name === 'a:picLocks');
388+
const picLocks = findChildByLocalName(cNvPicPr?.elements, 'picLocks');
388389
// Per OOXML §20.1.2.2.31, noChangeAspect defaults to false when not specified.
389390
// When a:picLocks is absent entirely, there is no lock → false.
390391
const lockAspectRatio = picLocks
@@ -395,8 +396,7 @@ export function handleImageNode(node, params, isAnchor) {
395396
// wp:docPr > a:hlinkClick (Word's canonical placement per §20.4.2.5).
396397
const cNvPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPr');
397398
const hlinkClick =
398-
cNvPr?.elements?.find((el) => el.name === 'a:hlinkClick') ||
399-
docPr?.elements?.find((el) => el.name === 'a:hlinkClick');
399+
findChildByLocalName(cNvPr?.elements, 'hlinkClick') || findChildByLocalName(docPr?.elements, 'hlinkClick');
400400
let hyperlink = null;
401401
if (hlinkClick?.attributes?.['r:id']) {
402402
const hlinkRId = hlinkClick.attributes['r:id'];
@@ -415,11 +415,11 @@ export function handleImageNode(node, params, isAnchor) {
415415

416416
// --- Parse decorative flag from wp:docPr > a:extLst > a:ext > adec:decorative ---
417417
let decorative = false;
418-
const docPrExtLst = docPr?.elements?.find((el) => el.name === 'a:extLst');
418+
const docPrExtLst = findChildByLocalName(docPr?.elements, 'extLst');
419419
if (docPrExtLst) {
420420
for (const ext of docPrExtLst.elements || []) {
421-
if (ext.name !== 'a:ext') continue;
422-
const decEl = ext.elements?.find((el) => el.name === 'adec:decorative' || el.name === 'a16:decorative');
421+
if (!hasLocalName(ext, 'ext')) continue;
422+
const decEl = findChildByLocalName(ext.elements, 'decorative');
423423
if (decEl && (decEl.attributes?.['val'] === '1' || decEl.attributes?.['val'] === 1)) {
424424
decorative = true;
425425
break;
@@ -603,7 +603,7 @@ const handleShapeDrawing = (
603603
const textBoxContent = textBox?.elements?.find((el) => el.name === 'w:txbxContent');
604604

605605
const spPr = wsp.elements.find((el) => el.name === 'wps:spPr');
606-
const prstGeom = spPr?.elements.find((el) => el.name === 'a:prstGeom');
606+
const prstGeom = findChildByLocalName(spPr?.elements, 'prstGeom');
607607
const shapeType = prstGeom?.attributes['prst'];
608608

609609
// Check for custom geometry when no preset geometry is found
@@ -681,15 +681,15 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
681681

682682
// Extract group properties
683683
const grpSpPr = wgp.elements.find((el) => el.name === 'wpg:grpSpPr');
684-
const xfrm = grpSpPr?.elements?.find((el) => el.name === 'a:xfrm');
684+
const xfrm = findChildByLocalName(grpSpPr?.elements, 'xfrm');
685685

686686
// Get group transform data
687687
const groupTransform = {};
688688
if (xfrm) {
689-
const off = xfrm.elements?.find((el) => el.name === 'a:off');
690-
const ext = xfrm.elements?.find((el) => el.name === 'a:ext');
691-
const chOff = xfrm.elements?.find((el) => el.name === 'a:chOff');
692-
const chExt = xfrm.elements?.find((el) => el.name === 'a:chExt');
689+
const off = findChildByLocalName(xfrm.elements, 'off');
690+
const ext = findChildByLocalName(xfrm.elements, 'ext');
691+
const chOff = findChildByLocalName(xfrm.elements, 'chOff');
692+
const chExt = findChildByLocalName(xfrm.elements, 'chExt');
693693

694694
if (off) {
695695
groupTransform.x = emuToPixels(off.attributes?.['x'] || 0);
@@ -723,14 +723,14 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
723723
if (!spPr) return null;
724724

725725
// Extract shape kind (preset geometry) or custom geometry
726-
const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom');
726+
const prstGeom = findChildByLocalName(spPr.elements, 'prstGeom');
727727
const shapeKind = prstGeom?.attributes?.['prst'];
728728
const customGeom = !shapeKind ? extractCustomGeometry(spPr) : null;
729729

730730
// Extract size and transformations
731-
const shapeXfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
732-
const shapeOff = shapeXfrm?.elements?.find((el) => el.name === 'a:off');
733-
const shapeExt = shapeXfrm?.elements?.find((el) => el.name === 'a:ext');
731+
const shapeXfrm = findChildByLocalName(spPr.elements, 'xfrm');
732+
const shapeOff = findChildByLocalName(shapeXfrm?.elements, 'off');
733+
const shapeExt = findChildByLocalName(shapeXfrm?.elements, 'ext');
734734

735735
// Get raw child coordinates in EMU
736736
const rawX = shapeOff?.attributes?.['x'] ? parseFloat(shapeOff.attributes['x']) : 0;
@@ -826,9 +826,9 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
826826
if (!spPr) return null;
827827

828828
// Extract size and transformations
829-
const xfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
830-
const off = xfrm?.elements?.find((el) => el.name === 'a:off');
831-
const ext = xfrm?.elements?.find((el) => el.name === 'a:ext');
829+
const xfrm = findChildByLocalName(spPr.elements, 'xfrm');
830+
const off = findChildByLocalName(xfrm?.elements, 'off');
831+
const ext = findChildByLocalName(xfrm?.elements, 'ext');
832832

833833
// Get raw coordinates in EMU
834834
const rawX = off?.attributes?.['x'] ? parseFloat(off.attributes['x']) : 0;
@@ -857,7 +857,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
857857

858858
// Extract image reference from blipFill
859859
const blipFill = pic.elements?.find((el) => el.name === 'pic:blipFill');
860-
const blip = blipFill?.elements?.find((el) => el.name === 'a:blip');
860+
const blip = findChildByLocalName(blipFill?.elements, 'blip');
861861
if (!blip) return null;
862862

863863
const rEmbed = blip.attributes?.['r:embed'];
@@ -1300,7 +1300,7 @@ export function getVectorShape({
13001300
}
13011301

13021302
// Extract shape kind (preset geometry) or custom geometry
1303-
const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom');
1303+
const prstGeom = findChildByLocalName(spPr.elements, 'prstGeom');
13041304
const shapeKind = prstGeom?.attributes?.['prst'];
13051305
schemaAttrs.kind = shapeKind;
13061306

@@ -1320,7 +1320,7 @@ export function getVectorShape({
13201320
const height = size?.height ?? DEFAULT_SHAPE_HEIGHT;
13211321

13221322
// Extract transformations from a:xfrm (rotation and flips are still valid)
1323-
const xfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
1323+
const xfrm = findChildByLocalName(spPr.elements, 'xfrm');
13241324
const rotation = xfrm?.attributes?.['rot'] ? rotToDegrees(xfrm.attributes['rot']) : 0;
13251325
const flipH = xfrm?.attributes?.['flipH'] === '1';
13261326
const flipV = xfrm?.attributes?.['flipV'] === '1';

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ describe('handleImageNode', () => {
166166
};
167167
};
168168

169+
const renameDrawingMlPrefix = (node, prefix) => {
170+
if (!node || typeof node !== 'object') return;
171+
if (typeof node.name === 'string' && node.name.startsWith('a:')) {
172+
node.name = `${prefix}:${node.name.slice(2)}`;
173+
}
174+
if (Array.isArray(node.elements)) {
175+
node.elements.forEach((child) => renameDrawingMlPrefix(child, prefix));
176+
}
177+
};
178+
169179
it('returns null if picture is missing', () => {
170180
const node = makeNode();
171181
node.elements[1].elements[0].elements = [];
@@ -530,6 +540,87 @@ describe('handleImageNode', () => {
530540
expect(extractStrokeWidth).toHaveBeenCalled();
531541
});
532542

543+
it('handles DrawingML nodes with non-a prefixes', () => {
544+
const node = makeShapeNode({ prst: 'rect' });
545+
renameDrawingMlPrefix(node, 'ns6');
546+
547+
const result = handleImageNode(node, makeParams(), false);
548+
expect(result.type).toBe('vectorShape');
549+
expect(result.attrs.kind).toBe('rect');
550+
});
551+
552+
describe('decorative flag (adec/a16/re-prefixed namespaces)', () => {
553+
const buildNodeWithDecorative = ({
554+
extLstName = 'a:extLst',
555+
extName = 'a:ext',
556+
decorativeName = 'adec:decorative',
557+
val = '1',
558+
} = {}) => {
559+
const node = makeNode();
560+
const docPr = node.elements.find((el) => el.name === 'wp:docPr');
561+
docPr.elements = [
562+
{
563+
name: extLstName,
564+
elements: [
565+
{
566+
name: extName,
567+
attributes: { uri: '{C183D7F6-B498-43B3-948B-1728B52AA6E4}' },
568+
elements: [{ name: decorativeName, attributes: { val } }],
569+
},
570+
],
571+
},
572+
];
573+
return node;
574+
};
575+
576+
it('detects decorative=1 emitted with the canonical adec: prefix (Word default)', () => {
577+
const node = buildNodeWithDecorative({ decorativeName: 'adec:decorative' });
578+
const result = handleImageNode(node, makeParams(), false);
579+
expect(result.attrs.decorative).toBe(true);
580+
});
581+
582+
it('detects decorative=1 emitted with the legacy a16: prefix', () => {
583+
const node = buildNodeWithDecorative({ decorativeName: 'a16:decorative' });
584+
const result = handleImageNode(node, makeParams(), false);
585+
expect(result.attrs.decorative).toBe(true);
586+
});
587+
588+
it('detects decorative=1 when the namespace prefix has been re-aliased (e.g. ns7:)', () => {
589+
const node = buildNodeWithDecorative({
590+
extLstName: 'ns6:extLst',
591+
extName: 'ns6:ext',
592+
decorativeName: 'ns7:decorative',
593+
});
594+
const result = handleImageNode(node, makeParams(), false);
595+
expect(result.attrs.decorative).toBe(true);
596+
});
597+
598+
it('leaves decorative=false when the val attribute is missing or zero', () => {
599+
const node = buildNodeWithDecorative({ val: '0' });
600+
const result = handleImageNode(node, makeParams(), false);
601+
expect(result.attrs.decorative).toBe(false);
602+
});
603+
604+
it('leaves decorative=false when extLst has no decorative descendant', () => {
605+
const node = makeNode();
606+
const docPr = node.elements.find((el) => el.name === 'wp:docPr');
607+
docPr.elements = [
608+
{
609+
name: 'a:extLst',
610+
elements: [
611+
{
612+
name: 'a:ext',
613+
attributes: { uri: '{ANY}' },
614+
elements: [{ name: 'a14:useLocalDpi', attributes: { val: '0' } }],
615+
},
616+
],
617+
},
618+
];
619+
const result = handleImageNode(node, makeParams(), false);
620+
expect(result.attrs.decorative).toBe(false);
621+
});
622+
});
623+
533624
it('renders textbox shapes as vectorShapes with text content', () => {
534625
const node = makeShapeNode({ includeTextbox: true });
535626
const result = handleImageNode(node, makeParams(), false);

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/merge-drawing-children.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { carbonCopy } from '@core/utilities/carbonCopy.js';
2+
import { findChildByLocalName } from './drawingml-utils.js';
23

34
/**
45
* Merge drawing children while ensuring:
@@ -102,8 +103,8 @@ function fixZeroDrawingIds(merged, generated) {
102103
docPr.attributes.id = validId;
103104
}
104105

105-
const graphic = merged.find((el) => el?.name === 'a:graphic');
106-
const graphicData = graphic?.elements?.find((el) => el?.name === 'a:graphicData');
106+
const graphic = findChildByLocalName(merged, 'graphic');
107+
const graphicData = findChildByLocalName(graphic?.elements, 'graphicData');
107108
const pic = graphicData?.elements?.find((el) => el?.name === 'pic:pic');
108109
const nvPicPr = pic?.elements?.find((el) => el?.name === 'pic:nvPicPr');
109110
const cNvPr = nvPicPr?.elements?.find((el) => el?.name === 'pic:cNvPr');

0 commit comments

Comments
 (0)