|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import type { ContentShape } from 'document-content-model'; |
| 3 | +import type { Package } from '../../model/package'; |
| 4 | +import type { XmlElement } from '../../model/node'; |
| 5 | +import { el, txt } from '../../xml/fragment'; |
| 6 | +import { bytesToBase64 } from '../../util/base64'; |
| 7 | +import { readDrawFrame, walkDrawShapes } from './shapes'; |
| 8 | + |
| 9 | +function contentPackage(automaticStyleChildren: XmlElement[] = []): Package['parts'][string] { |
| 10 | + return { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:automatic-styles', {}, automaticStyleChildren)])] }; |
| 11 | +} |
| 12 | + |
| 13 | +function graphicStyle(name: string, attrs: Record<string, string>, extra: Record<string, string> = {}): XmlElement { |
| 14 | + return el('style:style', { 'style:name': name, 'style:family': 'graphic', ...extra }, [el('style:graphic-properties', attrs)]); |
| 15 | +} |
| 16 | + |
| 17 | +// Only the PNG magic-byte signature matters to sniffImageFormat -- the rest is arbitrary filler, not a real encoded image, matching ooxml.js's own read.test.ts convention. |
| 18 | +function tinyPngBase64(): string { |
| 19 | + return bytesToBase64(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])); |
| 20 | +} |
| 21 | + |
| 22 | +describe('readDrawFrame: geometry', () => { |
| 23 | + it('reads a plain, unrotated frame\'s own svg:x/svg:y/svg:width/svg:height with no group transform', () => { |
| 24 | + const frame = el('draw:frame', { 'svg:x': '10pt', 'svg:y': '20pt', 'svg:width': '100pt', 'svg:height': '50pt' }); |
| 25 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 26 | + expect(shape?.frame).toEqual({ xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 }); |
| 27 | + expect(shape?.rotationDeg).toBeUndefined(); |
| 28 | + }); |
| 29 | + |
| 30 | + it('returns undefined for a frame with no resolvable geometry at all (the documented inherited-positioning scope boundary)', () => { |
| 31 | + expect(readDrawFrame(el('draw:frame'), [], { parts: {} })).toBeUndefined(); |
| 32 | + }); |
| 33 | + |
| 34 | + it('reads the frame\'s own draw:name', () => { |
| 35 | + const frame = el('draw:frame', { 'draw:name': 'My Shape', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 36 | + expect(readDrawFrame(frame, [], { parts: {} })?.name).toBe('My Shape'); |
| 37 | + }); |
| 38 | +}); |
| 39 | + |
| 40 | +describe('readDrawFrame: insets from the graphic-family style cascade', () => { |
| 41 | + it('reads fo:padding-* from the frame\'s own draw:style-name -> graphic family style', () => { |
| 42 | + const gr1 = graphicStyle('gr1', { 'fo:padding-left': '0.25cm', 'fo:padding-top': '0.125cm', 'fo:padding-right': '0.25cm', 'fo:padding-bottom': '0.125cm' }); |
| 43 | + const pkg: Package = { parts: { 'content.xml': contentPackage([gr1]) } }; |
| 44 | + const frame = el('draw:frame', { 'draw:style-name': 'gr1', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 45 | + const shape = readDrawFrame(frame, [], pkg); |
| 46 | + expect(shape?.insetLeftPt).toBeCloseTo(0.25 * (72 / 2.54), 6); |
| 47 | + expect(shape?.insetTopPt).toBeCloseTo(0.125 * (72 / 2.54), 6); |
| 48 | + }); |
| 49 | + |
| 50 | + it('inherits padding via style:parent-style-name -- the real LibreOffice pattern where a shape\'s own automatic style rarely repeats "standard"\'s own padding declaration', () => { |
| 51 | + const standard = graphicStyle('standard', { 'fo:padding-left': '0.25cm', 'fo:padding-top': '0.125cm', 'fo:padding-right': '0.25cm', 'fo:padding-bottom': '0.125cm' }); |
| 52 | + const gr1 = graphicStyle('gr1', { 'fo:min-height': '1.867cm' }, { 'style:parent-style-name': 'standard' }); |
| 53 | + const pkg: Package = { parts: { 'content.xml': contentPackage([standard, gr1]) } }; |
| 54 | + const frame = el('draw:frame', { 'draw:style-name': 'gr1', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 55 | + const shape = readDrawFrame(frame, [], pkg); |
| 56 | + expect(shape?.insetLeftPt).toBeCloseTo(0.25 * (72 / 2.54), 6); |
| 57 | + expect(shape?.insetBottomPt).toBeCloseTo(0.125 * (72 / 2.54), 6); |
| 58 | + }); |
| 59 | + |
| 60 | + it('defaults every inset to 0 when the frame has no draw:style-name at all', () => { |
| 61 | + const frame = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 62 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 63 | + expect(shape).toMatchObject({ insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0 }); |
| 64 | + }); |
| 65 | +}); |
| 66 | + |
| 67 | +describe('readDrawFrame: content dispatch', () => { |
| 68 | + const box = { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '100pt', 'svg:height': '50pt' }; |
| 69 | + |
| 70 | + it('reads a draw:text-box\'s own text:p children as paragraph blocks', () => { |
| 71 | + const frame = el('draw:frame', box, [el('draw:text-box', {}, [el('text:p', {}, [txt('Hello')])])]); |
| 72 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 73 | + expect(shape?.blocks).toEqual([{ kind: 'paragraph', runs: [{ text: 'Hello', bold: undefined, italic: undefined, underline: undefined, strike: undefined, fontFamily: undefined, sizePt: undefined, color: undefined }], styleId: undefined, alignment: undefined, spacingBeforePt: undefined, spacingAfterPt: undefined, lineSpacing: undefined, indentLeftPt: undefined, indentFirstLinePt: undefined }]); |
| 74 | + }); |
| 75 | + |
| 76 | + it('flattens a text:list\'s own text:list-item > text:p paragraphs (list numbering membership is a documented, separate gap -- text is never dropped)', () => { |
| 77 | + const list = el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('item one')])]), el('text:list-item', {}, [el('text:p', {}, [txt('item two')])])]); |
| 78 | + const frame = el('draw:frame', box, [el('draw:text-box', {}, [list])]); |
| 79 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 80 | + expect(shape?.blocks.map((b) => (b.kind === 'paragraph' ? b.runs.map((r) => r.text).join('') : undefined))).toEqual(['item one', 'item two']); |
| 81 | + }); |
| 82 | + |
| 83 | + it('reads a draw:image\'s referenced media part, sniffed and sized to the frame\'s own resolved box', () => { |
| 84 | + const pkg: Package = { parts: { 'Pictures/img1.png': { kind: 'binary', base64: tinyPngBase64() } } }; |
| 85 | + const frame = el('draw:frame', box, [el('draw:image', { 'xlink:href': 'Pictures/img1.png' })]); |
| 86 | + const shape = readDrawFrame(frame, [], pkg); |
| 87 | + expect(shape?.blocks).toEqual([{ kind: 'image', format: 'png', base64: tinyPngBase64(), widthPt: 100, heightPt: 50 }]); |
| 88 | + }); |
| 89 | + |
| 90 | + it('returns no blocks (not a thrown error) for a draw:image whose referenced part is missing', () => { |
| 91 | + const frame = el('draw:frame', box, [el('draw:image', { 'xlink:href': 'Pictures/missing.png' })]); |
| 92 | + expect(readDrawFrame(frame, [], { parts: {} })?.blocks).toEqual([]); |
| 93 | + }); |
| 94 | + |
| 95 | + it('reads a table:table child as a single ContentTable block', () => { |
| 96 | + const table = el('table:table', {}, [el('table:table-row', {}, [el('table:table-cell', {}, [el('text:p', {}, [txt('cell')])])])]); |
| 97 | + const frame = el('draw:frame', box, [table]); |
| 98 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 99 | + expect(shape?.blocks).toHaveLength(1); |
| 100 | + expect(shape?.blocks[0]?.kind).toBe('table'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('prefers table:table over a sibling draw:image fallback preview -- the real LibreOffice-generated shape both a table frame and its own .svm preview image share', () => { |
| 104 | + const table = el('table:table', {}, [el('table:table-row', {}, [el('table:table-cell', {}, [el('text:p', {}, [txt('cell')])])])]); |
| 105 | + const preview = el('draw:image', { 'xlink:href': 'Pictures/TablePreview1.svm' }); |
| 106 | + const frame = el('draw:frame', box, [table, preview]); |
| 107 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 108 | + expect(shape?.blocks).toHaveLength(1); |
| 109 | + expect(shape?.blocks[0]?.kind).toBe('table'); |
| 110 | + }); |
| 111 | + |
| 112 | + it('reads an empty frame (no text-box/image/table child at all) as an empty blocks array', () => { |
| 113 | + const frame = el('draw:frame', box); |
| 114 | + expect(readDrawFrame(frame, [], { parts: {} })?.blocks).toEqual([]); |
| 115 | + }); |
| 116 | +}); |
| 117 | + |
| 118 | +describe('readDrawFrame: rotation via draw:transform', () => { |
| 119 | + it('composes into a center-pivoting frame + rotationDeg -- see transform.test.ts for the pixel-verified geometry this delegates to', () => { |
| 120 | + const frame = el('draw:frame', { 'svg:width': '200pt', 'svg:height': '60pt', 'draw:transform': 'rotate(1.5707963267948966) translate(100pt 100pt)' }); |
| 121 | + const shape = readDrawFrame(frame, [], { parts: {} }); |
| 122 | + expect(shape?.frame.xPt).toBeCloseTo(30, 6); |
| 123 | + expect(shape?.frame.yPt).toBeCloseTo(-30, 6); |
| 124 | + expect(shape?.rotationDeg).toBeCloseTo(-90, 6); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('walkDrawShapes: flat, non-grouped content', () => { |
| 129 | + it('collects every draw:frame at the top level, in document order', () => { |
| 130 | + const frame1 = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 131 | + const frame2 = el('draw:frame', { 'svg:x': '20pt', 'svg:y': '20pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 132 | + const out: ContentShape[] = []; |
| 133 | + walkDrawShapes([frame1, frame2], [], { parts: {} }, out); |
| 134 | + expect(out.map((s) => s.frame.xPt)).toEqual([0, 20]); |
| 135 | + }); |
| 136 | + |
| 137 | + it('skips a top-level element that is neither draw:frame nor draw:g (a bare vector-primitive shape, out of this task\'s documented scope)', () => { |
| 138 | + const out: ContentShape[] = []; |
| 139 | + walkDrawShapes([el('draw:rect', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' })], [], { parts: {} }, out); |
| 140 | + expect(out).toEqual([]); |
| 141 | + }); |
| 142 | + |
| 143 | + it('drops a draw:frame with no resolvable geometry rather than pushing a fabricated shape', () => { |
| 144 | + const out: ContentShape[] = []; |
| 145 | + walkDrawShapes([el('draw:frame')], [], { parts: {} }, out); |
| 146 | + expect(out).toEqual([]); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe('walkDrawShapes: draw:g group flattening', () => { |
| 151 | + it('flattens a group\'s children into the parent\'s own flat shape list -- a real LibreOffice-generated group carries NO draw:transform of its own, so children keep their own literal, already-page-space coordinates unchanged', () => { |
| 152 | + const shapeA = el('draw:frame', { 'draw:name': 'A', 'svg:x': '50pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' }); |
| 153 | + const shapeB = el('draw:frame', { 'draw:name': 'B', 'svg:x': '150pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' }); |
| 154 | + const group = el('draw:g', {}, [shapeA, shapeB]); |
| 155 | + const out: ContentShape[] = []; |
| 156 | + walkDrawShapes([group], [], { parts: {} }, out); |
| 157 | + expect(out).toHaveLength(2); |
| 158 | + expect(out[0]).toMatchObject({ name: 'A', frame: { xPt: 50, yPt: 50, widthPt: 80, heightPt: 40 } }); |
| 159 | + expect(out[1]).toMatchObject({ name: 'B', frame: { xPt: 150, yPt: 50, widthPt: 80, heightPt: 40 } }); |
| 160 | + }); |
| 161 | + |
| 162 | + it('composes a group\'s own draw:transform onto each child -- a concrete before/after example: child center (90,70) rotated+translated by the group becomes center (170,10)', () => { |
| 163 | + // Before: child A's own box is x:50 y:50 w:80 h:40 -> local center (90, 70). |
| 164 | + const shapeA = el('draw:frame', { 'svg:x': '50pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' }); |
| 165 | + // Group transform: rotate(pi/2) translate(100pt 100pt) -- verified against a real render in transform.test.ts. |
| 166 | + const group = el('draw:g', { 'draw:transform': 'rotate(1.5707963267948966) translate(100pt 100pt)' }, [shapeA]); |
| 167 | + const out: ContentShape[] = []; |
| 168 | + walkDrawShapes([group], [], { parts: {} }, out); |
| 169 | + expect(out).toHaveLength(1); |
| 170 | + // After: applyOdfTransform(groupFunctions, {90,70}) -> rotate: (70,-90) -> translate: (170,10) -> frame top-left = center - halfSize = (170-40, 10-20) = (130,-10). |
| 171 | + expect(out[0]?.frame.xPt).toBeCloseTo(130, 6); |
| 172 | + expect(out[0]?.frame.yPt).toBeCloseTo(-10, 6); |
| 173 | + expect(out[0]?.frame.widthPt).toBeCloseTo(80, 6); // unchanged -- no scale in ODF's own group model |
| 174 | + expect(out[0]?.frame.heightPt).toBeCloseTo(40, 6); |
| 175 | + expect(out[0]?.rotationDeg).toBeCloseTo(-90, 6); |
| 176 | + }); |
| 177 | + |
| 178 | + it('composes NESTED groups innermost-first: an inner group\'s own transform applies to the child before the outer group\'s own transform applies to the result', () => { |
| 179 | + const shape = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' }); |
| 180 | + const inner = el('draw:g', { 'draw:transform': 'translate(10pt 0pt)' }, [shape]); |
| 181 | + const outer = el('draw:g', { 'draw:transform': 'translate(0pt 10pt)' }, [inner]); |
| 182 | + const out: ContentShape[] = []; |
| 183 | + walkDrawShapes([outer], [], { parts: {} }, out); |
| 184 | + // Child local center (5,5) -> inner translate (10,0) -> (15,5) -> outer translate (0,10) -> (15,15) -> top-left (10,10). |
| 185 | + expect(out[0]?.frame.xPt).toBeCloseTo(10, 6); |
| 186 | + expect(out[0]?.frame.yPt).toBeCloseTo(10, 6); |
| 187 | + }); |
| 188 | + |
| 189 | + it('flattens an EMPTY group (no children) into nothing, without error', () => { |
| 190 | + const out: ContentShape[] = []; |
| 191 | + walkDrawShapes([el('draw:g')], [], { parts: {} }, out); |
| 192 | + expect(out).toEqual([]); |
| 193 | + }); |
| 194 | +}); |
0 commit comments