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