@flighthq/scene2d-formats 0.2.1-next.840.857425c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lottieDocument.d.ts +8 -0
- package/dist/lottieDocument.d.ts.map +1 -0
- package/dist/lottieDocument.js +894 -0
- package/dist/lottieDocument.js.map +1 -0
- package/dist/svgDocument.d.ts +14 -0
- package/dist/svgDocument.d.ts.map +1 -0
- package/dist/svgDocument.js +1124 -0
- package/dist/svgDocument.js.map +1 -0
- package/package.json +57 -0
- package/src/lottieDocument.test.ts +429 -0
- package/src/svgDocument.test.ts +535 -0
- package/src/svgDocumentConformance.test.ts +539 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { createRectangle } from '@flighthq/geometry';
|
|
2
|
+
import { createImageResource } from '@flighthq/image';
|
|
3
|
+
import { getNodeChildAt, getNodeChildCount } from '@flighthq/node';
|
|
4
|
+
import { getShapeBounds } from '@flighthq/shape';
|
|
5
|
+
import type { Node2D, ImportDiagnostic, RichText, Shape } from '@flighthq/types';
|
|
6
|
+
import { ShapeKind } from '@flighthq/types';
|
|
7
|
+
|
|
8
|
+
import { createScene2DFromSvgDocument } from './svgDocument';
|
|
9
|
+
|
|
10
|
+
describe('SVG conformance matrix', () => {
|
|
11
|
+
it.each([
|
|
12
|
+
{
|
|
13
|
+
element: '<rect x="3" width="10" height="10" transform="translate(10 5)"/>',
|
|
14
|
+
expectedY: 5,
|
|
15
|
+
kind: 'shape',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
element: '<image href="asset.png" x="3" width="10" height="10" transform="translate(10 5)"/>',
|
|
19
|
+
expectedY: 5,
|
|
20
|
+
kind: 'image',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
element: '<text x="3" y="15" font-size="10" transform="translate(10 5)">T</text>',
|
|
24
|
+
expectedY: 10,
|
|
25
|
+
kind: 'text',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
element: '<g transform="translate(10 5)"><rect x="3" width="10" height="10"/></g>',
|
|
29
|
+
expectedY: 5,
|
|
30
|
+
kind: 'group',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
element: '<use href="#mark" x="3" transform="translate(10 5)"/>',
|
|
34
|
+
expectedY: 5,
|
|
35
|
+
kind: 'use',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
element: '<use href="#symbol" x="3" width="10" height="10" transform="translate(10 5)"/>',
|
|
39
|
+
expectedY: 5,
|
|
40
|
+
kind: 'symbol',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
element: '<use href="#nested" x="3" transform="translate(10 5)"/>',
|
|
44
|
+
expectedY: 5,
|
|
45
|
+
kind: 'nested-use',
|
|
46
|
+
},
|
|
47
|
+
])('composes geometry before transforms for $kind', ({ element, expectedY, kind }) => {
|
|
48
|
+
const image = createImageResource();
|
|
49
|
+
image.width = 10;
|
|
50
|
+
image.height = 10;
|
|
51
|
+
const root = createScene2DFromSvgDocument(
|
|
52
|
+
`
|
|
53
|
+
<svg>
|
|
54
|
+
<defs>
|
|
55
|
+
<g id="mark"><rect width="1" height="1"/></g>
|
|
56
|
+
<symbol id="symbol" viewBox="0 0 10 10"><rect width="10" height="10"/></symbol>
|
|
57
|
+
<g id="nested"><use href="#mark"/></g>
|
|
58
|
+
</defs>
|
|
59
|
+
${element}
|
|
60
|
+
</svg>
|
|
61
|
+
`,
|
|
62
|
+
undefined,
|
|
63
|
+
{ resolveImageResource: () => image },
|
|
64
|
+
);
|
|
65
|
+
const target = getNodeChildAt(root, 0)!;
|
|
66
|
+
const shape = findFirstShape(target);
|
|
67
|
+
|
|
68
|
+
if (kind === 'image' || kind === 'text' || kind === 'use' || kind === 'symbol' || kind === 'nested-use') {
|
|
69
|
+
expect(target.x).toBe(13);
|
|
70
|
+
} else {
|
|
71
|
+
expect(target.x + getShapeLocalX(shape!)).toBe(13);
|
|
72
|
+
}
|
|
73
|
+
expect(target.y).toBe(expectedY);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each([
|
|
77
|
+
{ kind: 'shape', target: '<rect width="200" height="100" clip-path="url(#half)"/>', width: 100 },
|
|
78
|
+
{ kind: 'image', target: '<image href="asset.png" width="200" height="100" clip-path="url(#half)"/>', width: 10 },
|
|
79
|
+
{
|
|
80
|
+
kind: 'group',
|
|
81
|
+
target: '<g clip-path="url(#half)"><rect width="200" height="100"/></g>',
|
|
82
|
+
width: 100,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
kind: 'use',
|
|
86
|
+
target: '<use href="#panel" width="200" height="100" clip-path="url(#half)"/>',
|
|
87
|
+
width: 100,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: 'nested-use',
|
|
91
|
+
target: '<use href="#nestedPanel" clip-path="url(#half)"/>',
|
|
92
|
+
width: 100,
|
|
93
|
+
},
|
|
94
|
+
])('maps objectBoundingBox clip units for $kind', ({ target, width }) => {
|
|
95
|
+
const image = createImageResource();
|
|
96
|
+
image.width = 20;
|
|
97
|
+
image.height = 10;
|
|
98
|
+
const root = createScene2DFromSvgDocument(
|
|
99
|
+
`
|
|
100
|
+
<svg>
|
|
101
|
+
<defs>
|
|
102
|
+
<clipPath id="half" clipPathUnits="objectBoundingBox"><rect width=".5" height="1"/></clipPath>
|
|
103
|
+
<symbol id="panel" viewBox="0 0 20 10" preserveAspectRatio="none">
|
|
104
|
+
<rect width="20" height="10"/>
|
|
105
|
+
</symbol>
|
|
106
|
+
<g id="panelGeometry"><rect width="200" height="100"/></g>
|
|
107
|
+
<g id="nestedPanel"><use href="#panelGeometry"/></g>
|
|
108
|
+
</defs>
|
|
109
|
+
${target}
|
|
110
|
+
</svg>
|
|
111
|
+
`,
|
|
112
|
+
undefined,
|
|
113
|
+
{ resolveImageResource: () => image },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(width);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('diagnoses objectBoundingBox clips when text bounds cannot be measured honestly', () => {
|
|
120
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
121
|
+
const root = createScene2DFromSvgDocument(
|
|
122
|
+
`
|
|
123
|
+
<svg>
|
|
124
|
+
<defs>
|
|
125
|
+
<clipPath id="half" clipPathUnits="objectBoundingBox"><rect width=".5" height="1"/></clipPath>
|
|
126
|
+
</defs>
|
|
127
|
+
<text clip-path="url(#half)">Text</text>
|
|
128
|
+
</svg>
|
|
129
|
+
`,
|
|
130
|
+
diagnostics,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(getNodeChildAt(root, 0)?.clip).toBeNull();
|
|
134
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.object-bounding-box-clip-without-bounds');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('instantiates path, symbol, and nested-use geometry inside clip paths', () => {
|
|
138
|
+
const root = createScene2DFromSvgDocument(`
|
|
139
|
+
<svg>
|
|
140
|
+
<defs>
|
|
141
|
+
<path id="clipPathShape" d="M0 0 H10 V10 H0 Z"/>
|
|
142
|
+
<symbol id="clipSymbol" viewBox="0 0 10 10" preserveAspectRatio="none">
|
|
143
|
+
<rect width="10" height="10"/>
|
|
144
|
+
</symbol>
|
|
145
|
+
<g id="clipNested"><use href="#clipPathShape" x="5"/></g>
|
|
146
|
+
<clipPath id="pathUse"><use href="#clipPathShape"/></clipPath>
|
|
147
|
+
<clipPath id="symbolUse"><use href="#clipSymbol" width="20" height="30"/></clipPath>
|
|
148
|
+
<clipPath id="nestedUse"><use href="#clipNested"/></clipPath>
|
|
149
|
+
</defs>
|
|
150
|
+
<rect width="40" height="40" clip-path="url(#pathUse)"/>
|
|
151
|
+
<rect width="40" height="40" clip-path="url(#symbolUse)"/>
|
|
152
|
+
<rect width="40" height="40" clip-path="url(#nestedUse)"/>
|
|
153
|
+
</svg>
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 0, y: 0 });
|
|
157
|
+
expect(getNodeChildAt(root, 1)?.clip?.rect).toMatchObject({ height: 30, width: 20, x: 0, y: 0 });
|
|
158
|
+
expect(getNodeChildAt(root, 2)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 5, y: 0 });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it.each([
|
|
162
|
+
{ kind: 'shape', target: '<rect width="200" height="100" mask="url(#halfMask)"/>', width: 100 },
|
|
163
|
+
{ kind: 'image', target: '<image href="asset.png" width="200" height="100" mask="url(#halfMask)"/>', width: 10 },
|
|
164
|
+
{
|
|
165
|
+
kind: 'group',
|
|
166
|
+
target: '<g mask="url(#halfMask)"><rect width="200" height="100"/></g>',
|
|
167
|
+
width: 100,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
kind: 'use',
|
|
171
|
+
target: '<use href="#panel" width="200" height="100" mask="url(#halfMask)"/>',
|
|
172
|
+
width: 100,
|
|
173
|
+
},
|
|
174
|
+
])('maps objectBoundingBox mask content for $kind', ({ target, width }) => {
|
|
175
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
176
|
+
const image = createImageResource();
|
|
177
|
+
image.width = 20;
|
|
178
|
+
image.height = 10;
|
|
179
|
+
const root = createScene2DFromSvgDocument(
|
|
180
|
+
`
|
|
181
|
+
<svg>
|
|
182
|
+
<defs>
|
|
183
|
+
<mask id="halfMask" maskContentUnits="objectBoundingBox"><rect width=".5" height="1"/></mask>
|
|
184
|
+
<symbol id="panel" viewBox="0 0 20 10" preserveAspectRatio="none">
|
|
185
|
+
<rect width="20" height="10"/>
|
|
186
|
+
</symbol>
|
|
187
|
+
</defs>
|
|
188
|
+
${target}
|
|
189
|
+
</svg>
|
|
190
|
+
`,
|
|
191
|
+
diagnostics,
|
|
192
|
+
{ resolveImageResource: () => image },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(width);
|
|
196
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.mask-as-hard-clip');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('shares use resolution and display suppression between masks and clip paths', () => {
|
|
200
|
+
const root = createScene2DFromSvgDocument(`
|
|
201
|
+
<svg>
|
|
202
|
+
<defs>
|
|
203
|
+
<path id="maskShape" d="M0 0 H10 V10 H0 Z"/>
|
|
204
|
+
<mask id="usedMask">
|
|
205
|
+
<rect width="10" height="10" display="none"/>
|
|
206
|
+
<use href="#maskShape" x="10"/>
|
|
207
|
+
</mask>
|
|
208
|
+
</defs>
|
|
209
|
+
<rect width="30" height="20" mask="url(#usedMask)"/>
|
|
210
|
+
</svg>
|
|
211
|
+
`);
|
|
212
|
+
|
|
213
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 10, y: 0 });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('treats a resolved display-none clip definition as an empty clip', () => {
|
|
217
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
218
|
+
const root = createScene2DFromSvgDocument(
|
|
219
|
+
`
|
|
220
|
+
<svg>
|
|
221
|
+
<defs><clipPath id="empty"><rect width="10" height="10" display="none"/></clipPath></defs>
|
|
222
|
+
<rect width="20" height="20" clip-path="url(#empty)"/>
|
|
223
|
+
</svg>
|
|
224
|
+
`,
|
|
225
|
+
diagnostics,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 });
|
|
229
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('keeps an empty hidden mask as a hard clip and reports the recovery', () => {
|
|
233
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
234
|
+
const root = createScene2DFromSvgDocument(
|
|
235
|
+
`
|
|
236
|
+
<svg>
|
|
237
|
+
<defs><mask id="empty"><rect width="10" height="10" visibility="hidden"/></mask></defs>
|
|
238
|
+
<rect width="20" height="20" mask="url(#empty)"/>
|
|
239
|
+
</svg>
|
|
240
|
+
`,
|
|
241
|
+
diagnostics,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 });
|
|
245
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.mask-as-hard-clip');
|
|
246
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('diagnoses unsupported clip text while preserving empty-clip semantics', () => {
|
|
250
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
251
|
+
const root = createScene2DFromSvgDocument(
|
|
252
|
+
`
|
|
253
|
+
<svg>
|
|
254
|
+
<defs><clipPath id="text"><text>Clip</text></clipPath></defs>
|
|
255
|
+
<rect width="20" height="20" clip-path="url(#text)"/>
|
|
256
|
+
</svg>
|
|
257
|
+
`,
|
|
258
|
+
diagnostics,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 });
|
|
262
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.unsupported-clip-text');
|
|
263
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('intersects clip-path references on clip definitions and their children', () => {
|
|
267
|
+
const root = createScene2DFromSvgDocument(`
|
|
268
|
+
<svg>
|
|
269
|
+
<defs>
|
|
270
|
+
<clipPath id="narrow"><rect width="10" height="20"/></clipPath>
|
|
271
|
+
<clipPath id="definition" clip-path="url(#narrow)"><rect width="20" height="20"/></clipPath>
|
|
272
|
+
<clipPath id="child"><rect width="20" height="20" clip-path="url(#narrow)"/></clipPath>
|
|
273
|
+
</defs>
|
|
274
|
+
<rect width="30" height="30" clip-path="url(#definition)"/>
|
|
275
|
+
<rect width="30" height="30" clip-path="url(#child)"/>
|
|
276
|
+
</svg>
|
|
277
|
+
`);
|
|
278
|
+
|
|
279
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(10);
|
|
280
|
+
expect(getNodeChildAt(root, 1)?.clip?.rect.width).toBe(10);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('honestly skips a transformed objectBoundingBox nested clip intersection', () => {
|
|
284
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
285
|
+
const root = createScene2DFromSvgDocument(
|
|
286
|
+
`
|
|
287
|
+
<svg>
|
|
288
|
+
<defs>
|
|
289
|
+
<clipPath id="half" clipPathUnits="objectBoundingBox"><rect width=".5" height="1"/></clipPath>
|
|
290
|
+
<clipPath id="translated">
|
|
291
|
+
<rect width="10" height="10" transform="translate(10)" clip-path="url(#half)"/>
|
|
292
|
+
</clipPath>
|
|
293
|
+
</defs>
|
|
294
|
+
<rect width="30" height="20" clip-path="url(#translated)"/>
|
|
295
|
+
</svg>
|
|
296
|
+
`,
|
|
297
|
+
diagnostics,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 10, y: 0 });
|
|
301
|
+
expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.clip-nested-intersection-unsupported');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('inherits clip-rule from a clip definition ancestor, not its referencing target', () => {
|
|
305
|
+
const root = createScene2DFromSvgDocument(`
|
|
306
|
+
<svg>
|
|
307
|
+
<defs>
|
|
308
|
+
<g clip-rule="evenodd">
|
|
309
|
+
<clipPath id="ancestorRule"><path d="M0 0 H20 V20 H0 Z M5 5 H15 V15 H5 Z"/></clipPath>
|
|
310
|
+
</g>
|
|
311
|
+
</defs>
|
|
312
|
+
<rect clip-rule="nonzero" width="20" height="20" clip-path="url(#ancestorRule)"/>
|
|
313
|
+
</svg>
|
|
314
|
+
`);
|
|
315
|
+
|
|
316
|
+
expect(getNodeChildAt(root, 0)?.clip?.winding).toBe('evenOdd');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('keeps fill and clip winding properties independently inherited', () => {
|
|
320
|
+
const root = createScene2DFromSvgDocument(`
|
|
321
|
+
<svg>
|
|
322
|
+
<defs>
|
|
323
|
+
<clipPath id="fillOnly" fill-rule="evenodd"><path d="M0 0 H10 V10 H0 Z"/></clipPath>
|
|
324
|
+
<clipPath id="clipOnly" clip-rule="evenodd"><path d="M0 0 H10 V10 H0 Z"/></clipPath>
|
|
325
|
+
</defs>
|
|
326
|
+
<g fill-rule="evenodd"><path d="M0 0 H10 V10 H0 Z"/></g>
|
|
327
|
+
<rect width="10" height="10" clip-path="url(#fillOnly)"/>
|
|
328
|
+
<rect width="10" height="10" clip-path="url(#clipOnly)"/>
|
|
329
|
+
</svg>
|
|
330
|
+
`);
|
|
331
|
+
|
|
332
|
+
const fillShape = findFirstShape(getNodeChildAt(root, 0)!);
|
|
333
|
+
expect(getShapePathWinding(fillShape!)).toBe('evenOdd');
|
|
334
|
+
expect(getNodeChildAt(root, 1)?.clip?.winding).toBe('nonZero');
|
|
335
|
+
expect(getNodeChildAt(root, 2)?.clip?.winding).toBe('evenOdd');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it.each([
|
|
339
|
+
{
|
|
340
|
+
expected: 0xffa500,
|
|
341
|
+
kind: 'shape',
|
|
342
|
+
target: '<rect class="accent" fill="blue" width="10" height="10"/>',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
expected: 0xff0000,
|
|
346
|
+
kind: 'group inheritance',
|
|
347
|
+
target: '<g class="theme"><rect width="10" height="10"/></g>',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
expected: 0xff0000,
|
|
351
|
+
kind: 'use inheritance',
|
|
352
|
+
target: '<use href="#mark" class="theme"/>',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
expected: 0xff0000,
|
|
356
|
+
kind: 'symbol inheritance',
|
|
357
|
+
target: '<use href="#symbol" class="theme"/>',
|
|
358
|
+
},
|
|
359
|
+
])('applies CSS cascade and presentation inheritance for $kind', ({ expected, target }) => {
|
|
360
|
+
const root = createScene2DFromSvgDocument(`
|
|
361
|
+
<svg>
|
|
362
|
+
<style>
|
|
363
|
+
.accent { fill: red }
|
|
364
|
+
.accent { fill: orange }
|
|
365
|
+
.theme { fill: red }
|
|
366
|
+
</style>
|
|
367
|
+
<defs>
|
|
368
|
+
<g id="mark"><rect width="10" height="10"/></g>
|
|
369
|
+
<symbol id="symbol"><rect width="10" height="10"/></symbol>
|
|
370
|
+
</defs>
|
|
371
|
+
${target}
|
|
372
|
+
</svg>
|
|
373
|
+
`);
|
|
374
|
+
const shape = findFirstShape(getNodeChildAt(root, 0)!)!;
|
|
375
|
+
|
|
376
|
+
expect(getShapeFillColor(shape)).toBe(expected);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('applies stylesheet cascade to text and tspan runs', () => {
|
|
380
|
+
const root = createScene2DFromSvgDocument(`
|
|
381
|
+
<svg>
|
|
382
|
+
<style>
|
|
383
|
+
text { fill: red }
|
|
384
|
+
.accent { fill: blue }
|
|
385
|
+
</style>
|
|
386
|
+
<text fill="green">A<tspan class="accent">B</tspan></text>
|
|
387
|
+
</svg>
|
|
388
|
+
`);
|
|
389
|
+
const text = getNodeChildAt(root, 0) as RichText;
|
|
390
|
+
|
|
391
|
+
expect(text.data.textFormat.color).toBe(0xff0000ff);
|
|
392
|
+
expect(text.data.textFormatRanges[0].format.color).toBe(0x0000ffff);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('suppresses display none while allowing visibility descendants to override', () => {
|
|
396
|
+
const root = createScene2DFromSvgDocument(`
|
|
397
|
+
<svg>
|
|
398
|
+
<g display="none"><rect width="10" height="10" display="inline"/></g>
|
|
399
|
+
<g visibility="hidden">
|
|
400
|
+
<rect width="10" height="10"/>
|
|
401
|
+
<rect x="20" width="10" height="10" visibility="visible"/>
|
|
402
|
+
</g>
|
|
403
|
+
</svg>
|
|
404
|
+
`);
|
|
405
|
+
const displayGroup = getNodeChildAt(root, 0)!;
|
|
406
|
+
const visibilityGroup = getNodeChildAt(root, 1)!;
|
|
407
|
+
|
|
408
|
+
expect(displayGroup.visible).toBe(false);
|
|
409
|
+
expect(getNodeChildAt(displayGroup, 0)?.visible).toBe(true);
|
|
410
|
+
expect(visibilityGroup.visible).toBe(true);
|
|
411
|
+
expect(getNodeChildAt(visibilityGroup, 0)?.visible).toBe(false);
|
|
412
|
+
expect(getNodeChildAt(visibilityGroup, 1)?.visible).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('keeps display at its initial value on descendants of a display-none ancestor', () => {
|
|
416
|
+
const root = createScene2DFromSvgDocument(`
|
|
417
|
+
<svg><g display="none"><rect width="10" height="10"/></g></svg>
|
|
418
|
+
`);
|
|
419
|
+
const group = getNodeChildAt(root, 0)!;
|
|
420
|
+
|
|
421
|
+
expect(group.visible).toBe(false);
|
|
422
|
+
expect(getNodeChildAt(group, 0)?.visible).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it.each([
|
|
426
|
+
{ kind: 'shape', path: [0], target: '<rect width="10" height="10" display="none"/>' },
|
|
427
|
+
{ kind: 'image', path: [0], target: '<image href="asset.png" display="none"/>' },
|
|
428
|
+
{ kind: 'text', path: [0], target: '<text display="none">T</text>' },
|
|
429
|
+
{ kind: 'group', path: [0], target: '<g display="none"><rect width="10" height="10"/></g>' },
|
|
430
|
+
{ kind: 'use', path: [0], target: '<use href="#mark" display="none"/>' },
|
|
431
|
+
{ kind: 'symbol', path: [0, 0], target: '<use href="#hiddenSymbol"/>' },
|
|
432
|
+
{ kind: 'nested-use', path: [0, 0], target: '<use href="#hiddenNested"/>' },
|
|
433
|
+
])('suppresses display none for $kind', ({ path, target }) => {
|
|
434
|
+
const image = createImageResource();
|
|
435
|
+
image.width = 10;
|
|
436
|
+
image.height = 10;
|
|
437
|
+
const root = createScene2DFromSvgDocument(
|
|
438
|
+
`
|
|
439
|
+
<svg>
|
|
440
|
+
<defs>
|
|
441
|
+
<g id="mark"><rect width="10" height="10"/></g>
|
|
442
|
+
<symbol id="hiddenSymbol" display="none"><rect width="10" height="10"/></symbol>
|
|
443
|
+
<g id="hiddenNested" display="none"><use href="#mark"/></g>
|
|
444
|
+
</defs>
|
|
445
|
+
${target}
|
|
446
|
+
</svg>
|
|
447
|
+
`,
|
|
448
|
+
undefined,
|
|
449
|
+
{ resolveImageResource: () => image },
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
expect(getDescendant(root, path).visible).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('excludes hidden clip graphics and permits descendant visibility overrides', () => {
|
|
456
|
+
const root = createScene2DFromSvgDocument(`
|
|
457
|
+
<svg>
|
|
458
|
+
<defs>
|
|
459
|
+
<clipPath id="visibility">
|
|
460
|
+
<rect width="10" height="10" display="none"/>
|
|
461
|
+
<g visibility="hidden">
|
|
462
|
+
<rect x="10" width="10" height="10"/>
|
|
463
|
+
<rect x="20" width="10" height="10" visibility="visible"/>
|
|
464
|
+
</g>
|
|
465
|
+
</clipPath>
|
|
466
|
+
</defs>
|
|
467
|
+
<rect width="40" height="40" clip-path="url(#visibility)"/>
|
|
468
|
+
</svg>
|
|
469
|
+
`);
|
|
470
|
+
|
|
471
|
+
expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 20, y: 0 });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it.each([
|
|
475
|
+
{
|
|
476
|
+
expected: { scaleX: 4, scaleY: 2, x: 0 },
|
|
477
|
+
kind: 'root SVG',
|
|
478
|
+
source: '<svg width="200" height="100" viewBox="0 0 50 50" preserveAspectRatio="none"/>',
|
|
479
|
+
target: 'root',
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
expected: { scaleX: 2, scaleY: 3, x: 5 },
|
|
483
|
+
kind: 'nested SVG',
|
|
484
|
+
source: '<svg><svg x="5" width="20" height="30" viewBox="0 0 10 10" preserveAspectRatio="none"/></svg>',
|
|
485
|
+
target: 'child',
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
expected: { scaleX: 2, scaleY: 3, x: 0 },
|
|
489
|
+
kind: 'symbol viewport',
|
|
490
|
+
source:
|
|
491
|
+
'<svg><defs><symbol id="s" viewBox="0 0 10 10" preserveAspectRatio="none"/></defs><use href="#s" width="20" height="30"/></svg>',
|
|
492
|
+
target: 'symbol',
|
|
493
|
+
},
|
|
494
|
+
])('maps viewBox and viewport sizing for $kind', ({ expected, source, target }) => {
|
|
495
|
+
const root = createScene2DFromSvgDocument(source);
|
|
496
|
+
const node =
|
|
497
|
+
target === 'root'
|
|
498
|
+
? root
|
|
499
|
+
: target === 'child'
|
|
500
|
+
? getNodeChildAt(root, 0)!
|
|
501
|
+
: getNodeChildAt(getNodeChildAt(root, 0)!, 0)!;
|
|
502
|
+
|
|
503
|
+
expect(node).toMatchObject(expected);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
function findFirstShape(target: Node2D): Shape | null {
|
|
508
|
+
if (target.kind === ShapeKind) return target as Shape;
|
|
509
|
+
const count = getNodeChildCount(target);
|
|
510
|
+
for (let index = 0; index < count; index++) {
|
|
511
|
+
const child = getNodeChildAt(target, index) as Node2D | null;
|
|
512
|
+
if (child === null) continue;
|
|
513
|
+
const shape = findFirstShape(child);
|
|
514
|
+
if (shape !== null) return shape;
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function getShapeFillColor(shape: Shape): number | null {
|
|
520
|
+
const index = shape.data.commands.indexOf('beginFill');
|
|
521
|
+
return index === -1 ? null : (shape.data.commands[index + 2] as number);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function getShapeLocalX(shape: Shape): number {
|
|
525
|
+
const bounds = createRectangle();
|
|
526
|
+
getShapeBounds(bounds, shape);
|
|
527
|
+
return bounds.x;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function getShapePathWinding(shape: Shape): unknown {
|
|
531
|
+
const index = shape.data.commands.indexOf('drawPath');
|
|
532
|
+
return index === -1 ? null : shape.data.commands[index + 4];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function getDescendant(root: Node2D, path: number[]): Node2D {
|
|
536
|
+
let node = root;
|
|
537
|
+
for (const index of path) node = getNodeChildAt(node, index) as Node2D;
|
|
538
|
+
return node;
|
|
539
|
+
}
|