Skip to content

Commit b166291

Browse files
authored
fix: prevent duplicate compatibility text boxes (#476)
1 parent 7f5ab65 commit b166291

5 files changed

Lines changed: 162 additions & 4 deletions

File tree

.changeset/quiet-textboxes-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stll/folio-core": patch
3+
---
4+
5+
Prevent compatibility text boxes from being duplicated during document saves.

packages/core/src/docx/paragraphTextBoxEnrichment.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,14 @@ const scanRunForTextBoxDrawings = (xmlRun: XmlElement): TextBoxRunScan => {
383383
}
384384
if (name === "pict") {
385385
if (findDeep(el, "v", "textbox")) {
386-
vmlTextBoxes.push(el);
386+
// An image-backed VML container is preserved as one raw drawing by
387+
// runParser. Adding an editable text-box shape here would serialize a
388+
// second representation beside that raw replay on every save.
389+
if (findDeep(el, "v", "imagedata")) {
390+
hasNonTextBoxContent = true;
391+
} else {
392+
vmlTextBoxes.push(el);
393+
}
387394
} else {
388395
hasNonTextBoxContent = true;
389396
}

packages/core/src/docx/runParser.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
} from "./parserEnums";
5858
import { parseShapeFromDrawing, shouldPreserveRawShapeDrawing } from "./shapeParser";
5959
import type { StyleMap } from "./styleParser";
60+
import { isTextBoxDrawing } from "./textBoxParser";
6061
import { parseVmlImageContent } from "./vmlImageParser";
6162
import { resolveThemeFontRef } from "./themeParser";
6263
import { requiresXmlSpacePreserve } from "./textWhitespace";
@@ -1022,6 +1023,11 @@ function parseRunContents(
10221023
const alternateChildren = getChildElements(child);
10231024
const choiceEl = alternateChildren.find((el) => getLocalName(el.name) === "Choice");
10241025
const fallbackEl = alternateChildren.find((el) => getLocalName(el.name) === "Fallback");
1026+
const choiceTextBoxDrawing = choiceEl
1027+
? getChildElements(choiceEl).find(
1028+
(element) => getLocalName(element.name) === "drawing" && isTextBoxDrawing(element),
1029+
)
1030+
: undefined;
10251031

10261032
const groupedChoiceDrawing = choiceEl
10271033
? getChildElements(choiceEl).find(
@@ -1042,9 +1048,10 @@ function parseRunContents(
10421048
const fallbackPict = fallbackEl
10431049
? getChildElements(fallbackEl).find((el) => getLocalName(el.name) === "pict")
10441050
: undefined;
1045-
const fallbackVml = fallbackPict
1046-
? parseVmlImageContent(fallbackPict, rels, media, rootXmlns)
1047-
: null;
1051+
const fallbackVml =
1052+
fallbackPict && !choiceTextBoxDrawing
1053+
? parseVmlImageContent(fallbackPict, rels, media, rootXmlns)
1054+
: null;
10481055
if (fallbackVml?.image.src) {
10491056
fallbackVml.rawXml = elementToXml(cloneWithXmlnsDeclarations(child, rootXmlns));
10501057
contents.push(fallbackVml);
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, test } from "bun:test";
2+
import JSZip from "jszip";
3+
4+
import { parseDocx } from "./parser";
5+
import { createEmptyDocx, repackDocx } from "./rezip";
6+
7+
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8+
const IMAGE_RELATIONSHIP_TYPE =
9+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
10+
11+
const textBoxDrawing = (text: string): string => `
12+
<w:drawing>
13+
<wp:anchor simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">
14+
<wp:simplePos x="0" y="0"/>
15+
<wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH>
16+
<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
17+
<wp:extent cx="1828800" cy="914400"/>
18+
<wp:wrapSquare wrapText="bothSides"/>
19+
<wp:docPr id="1" name="Text box"/>
20+
<a:graphic>
21+
<a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
22+
<wps:wsp>
23+
<wps:spPr>
24+
<a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
25+
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
26+
</wps:spPr>
27+
<wps:txbx>
28+
<w:txbxContent><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:txbxContent>
29+
</wps:txbx>
30+
<wps:bodyPr/>
31+
</wps:wsp>
32+
</a:graphicData>
33+
</a:graphic>
34+
</wp:anchor>
35+
</w:drawing>`;
36+
37+
const documentXml = (runContent: string): string => `${XML_DECLARATION}
38+
<w:document
39+
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
40+
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
41+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
42+
xmlns:v="urn:schemas-microsoft-com:vml"
43+
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
44+
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
45+
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
46+
<w:body><w:p><w:r>${runContent}</w:r></w:p><w:sectPr/></w:body>
47+
</w:document>`;
48+
49+
const buildDocx = async (runContent: string, includeImage = false): Promise<ArrayBuffer> => {
50+
const zip = await JSZip.loadAsync(await createEmptyDocx());
51+
zip.file("word/document.xml", documentXml(runContent));
52+
53+
if (includeImage) {
54+
const relsPath = "word/_rels/document.xml.rels";
55+
const relationships = await zip.file(relsPath)!.async("text");
56+
zip.file(
57+
relsPath,
58+
relationships.replace(
59+
"</Relationships>",
60+
`<Relationship Id="rIdImage" Type="${IMAGE_RELATIONSHIP_TYPE}" Target="media/preview.png"/></Relationships>`,
61+
),
62+
);
63+
zip.file("word/media/preview.png", new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]));
64+
}
65+
66+
return zip.generateAsync({ type: "arraybuffer" });
67+
};
68+
69+
type ParsedDocx = Awaited<ReturnType<typeof parseDocx>>;
70+
71+
const bodyDrawingTypes = ({ package: { document } }: ParsedDocx): string[] =>
72+
document.content.flatMap((block) =>
73+
block.type === "paragraph"
74+
? block.content.flatMap((content) =>
75+
content.type === "run" ? content.content.map(({ type }) => type) : [],
76+
)
77+
: [],
78+
);
79+
80+
const savedDocumentXml = async (buffer: ArrayBuffer): Promise<string> => {
81+
const zip = await JSZip.loadAsync(buffer);
82+
return zip.file("word/document.xml")!.async("text");
83+
};
84+
85+
describe("text-box drawing ownership", () => {
86+
test("a modern AlternateContent text box owns its VML fallback exactly once", async () => {
87+
const source = await buildDocx(`
88+
<mc:AlternateContent>
89+
<mc:Choice Requires="wps">${textBoxDrawing("Editable text")}</mc:Choice>
90+
<mc:Fallback>
91+
<w:pict>
92+
<v:rect style="width:2in;height:1in">
93+
<v:textbox><w:txbxContent><w:p><w:r><w:t>Fallback text</w:t></w:r></w:p></w:txbxContent></v:textbox>
94+
</v:rect>
95+
</w:pict>
96+
</mc:Fallback>
97+
</mc:AlternateContent>`);
98+
const parsed = await parseDocx(source, { preloadFonts: false });
99+
100+
expect(bodyDrawingTypes(parsed)).toEqual(["shape"]);
101+
102+
const saved = await repackDocx(parsed, { updateModifiedDate: false });
103+
const reopened = await parseDocx(saved, { preloadFonts: false });
104+
expect(bodyDrawingTypes(reopened)).toEqual(["shape"]);
105+
expect((await savedDocumentXml(saved)).match(/<w:drawing(?:\s|>)/gu)).toHaveLength(1);
106+
});
107+
108+
test("an image-backed VML text-box container stays one raw drawing", async () => {
109+
const source = await buildDocx(
110+
`<w:pict>
111+
<v:shape style="width:2in;height:1in">
112+
<v:imagedata r:id="rIdImage"/>
113+
<v:textbox><w:txbxContent><w:p><w:r><w:t>Legacy overlay</w:t></w:r></w:p></w:txbxContent></v:textbox>
114+
</v:shape>
115+
</w:pict>`,
116+
true,
117+
);
118+
const parsed = await parseDocx(source, { preloadFonts: false });
119+
120+
expect(bodyDrawingTypes(parsed)).toEqual(["drawing"]);
121+
122+
const saved = await repackDocx(parsed, { updateModifiedDate: false });
123+
const reopened = await parseDocx(saved, { preloadFonts: false });
124+
expect(bodyDrawingTypes(reopened)).toEqual(["drawing"]);
125+
const savedXml = await savedDocumentXml(saved);
126+
expect(savedXml.match(/<w:pict(?:\s|>)/gu)).toHaveLength(1);
127+
expect(savedXml).not.toContain("<w:drawing");
128+
});
129+
});

packages/core/src/docx/vmlImageParser.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
elementToXml,
3939
findAllDeep,
4040
findChild,
41+
findDeep,
4142
getChildElements,
4243
getAttribute,
4344
getLocalName,
@@ -234,6 +235,12 @@ const parseStandaloneShapePreview = (
234235
shape: XmlElement,
235236
rootXmlns: Record<string, string>,
236237
): DrawingContent | null => {
238+
// A VML text box is an editable document shape, not a preview image. Its
239+
// content is owned by paragraphTextBoxEnrichment; rendering it here as a
240+
// synthetic SVG would materialize the same OOXML object twice.
241+
if (findDeep(shape, "v", "textbox")) {
242+
return null;
243+
}
237244
const style = parseStyleAttr(getAttribute(shape, null, "style"));
238245
const widthPx = cssLengthToPx(style["width"]);
239246
const heightPx = cssLengthToPx(style["height"]);
@@ -271,6 +278,9 @@ const parseGroupPreview = (
271278
.slice(0, MAX_VML_PREVIEW_SHAPES)
272279
.map((child) => {
273280
const localName = getLocalName(child.name ?? "");
281+
if (findDeep(child, "v", "textbox")) {
282+
return "";
283+
}
274284
if (localName === "line") {
275285
const from = coordinatePair(getAttribute(child, null, "from"));
276286
const to = coordinatePair(getAttribute(child, null, "to"));

0 commit comments

Comments
 (0)