import { createRectangle } from '@flighthq/geometry/contract'; import { getNodeChildAt, getNodeChildCount } from '@flighthq/node/contract'; import { getShapeBounds } from '@flighthq/shape/contract'; import type { Node2D, ImportDiagnostic, RichText, Shape } from '@flighthq/types/contract'; import { ShapeKind } from '@flighthq/types/contract'; import { createScene2DFromSvgDocument } from './svgDocument'; import { createReadyImageResourceForTest } from './testHelper'; describe('SVG conformance matrix', () => { it.each([ { element: '', expectedY: 5, kind: 'shape', }, { element: '', expectedY: 5, kind: 'image', }, { element: 'T', expectedY: 10, kind: 'text', }, { element: '', expectedY: 5, kind: 'group', }, { element: '', expectedY: 5, kind: 'use', }, { element: '', expectedY: 5, kind: 'symbol', }, { element: '', expectedY: 5, kind: 'nested-use', }, ])('composes geometry before transforms for $kind', ({ element, expectedY, kind }) => { const image = createReadyImageResourceForTest(10, 10); const root = createScene2DFromSvgDocument( ` ${element} `, undefined, { resolveImageResource: () => image }, ); const target = getNodeChildAt(root, 0)!; const shape = findFirstShape(target); if (kind === 'image' || kind === 'text' || kind === 'use' || kind === 'symbol' || kind === 'nested-use') { expect(target.x).toBe(13); } else { expect(target.x + getShapeLocalX(shape!)).toBe(13); } expect(target.y).toBe(expectedY); }); it.each([ { kind: 'shape', target: '', width: 100 }, { kind: 'image', target: '', width: 10 }, { kind: 'group', target: '', width: 100, }, { kind: 'use', target: '', width: 100, }, { kind: 'nested-use', target: '', width: 100, }, ])('maps objectBoundingBox clip units for $kind', ({ target, width }) => { const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( ` ${target} `, undefined, { resolveImageResource: () => image }, ); expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(width); }); it('diagnoses objectBoundingBox clips when text bounds cannot be measured honestly', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` Text `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip).toBeNull(); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain( 'svg.object-bounding-box-clip-unmeasurable-bounds', ); }); it('instantiates path, symbol, and nested-use geometry inside clip paths', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 0, y: 0 }); expect(getNodeChildAt(root, 1)?.clip?.rect).toMatchObject({ height: 30, width: 20, x: 0, y: 0 }); expect(getNodeChildAt(root, 2)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 5, y: 0 }); }); it.each([ { kind: 'shape', target: '', width: 100 }, { kind: 'image', target: '', width: 10 }, { kind: 'group', target: '', width: 100, }, { kind: 'use', target: '', width: 100, }, ])('maps objectBoundingBox mask content for $kind', ({ target, width }) => { const diagnostics: ImportDiagnostic[] = []; const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( ` ${target} `, diagnostics, { resolveImageResource: () => image }, ); expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(width); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.mask-as-hard-clip'); }); it('shares use resolution and display suppression between masks and clip paths', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 10, y: 0 }); }); it('treats a resolved display-none clip definition as an empty clip', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 }); expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference'); }); it('keeps an empty hidden mask as a hard clip and reports the recovery', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 }); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.mask-as-hard-clip'); expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference'); }); it('diagnoses unsupported clip text while preserving empty-clip semantics', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` Clip `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 0, width: 0 }); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.unsupported-clip-text'); expect(diagnostics.map((diagnostic) => diagnostic.kind)).not.toContain('svg.unresolved-clip-reference'); }); it('intersects clip-path references on clip definitions and their children', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect.width).toBe(10); expect(getNodeChildAt(root, 1)?.clip?.rect.width).toBe(10); }); it('honestly skips a transformed objectBoundingBox nested clip intersection', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 10, y: 0 }); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toContain('svg.clip-nested-intersection-unsupported'); }); it('inherits clip-rule from a clip definition ancestor, not its referencing target', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.winding).toBe('evenOdd'); }); it('keeps fill and clip winding properties independently inherited', () => { const root = createScene2DFromSvgDocument(` `); const fillShape = findFirstShape(getNodeChildAt(root, 0)!); expect(getShapePathWinding(fillShape!)).toBe('evenOdd'); expect(getNodeChildAt(root, 1)?.clip?.winding).toBe('nonZero'); expect(getNodeChildAt(root, 2)?.clip?.winding).toBe('evenOdd'); }); it.each([ { expected: 0xffa500ff, kind: 'shape', target: '', }, { expected: 0xff0000ff, kind: 'group inheritance', target: '', }, { expected: 0xff0000ff, kind: 'use inheritance', target: '', }, { expected: 0xff0000ff, kind: 'symbol inheritance', target: '', }, ])('applies CSS cascade and presentation inheritance for $kind', ({ expected, target }) => { const root = createScene2DFromSvgDocument(` ${target} `); const shape = findFirstShape(getNodeChildAt(root, 0)!)!; expect(getShapeFillColor(shape)).toBe(expected); }); it('applies stylesheet cascade to text and tspan runs', () => { const root = createScene2DFromSvgDocument(` AB `); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.textFormat.color).toBe(0xff0000ff); expect(text.data.textFormatRanges[0].format.color).toBe(0x0000ffff); }); it('keeps fill-none text transparent while a painted tspan remains visible', () => { const root = createScene2DFromSvgDocument('AB'); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.text).toBe('AB'); expect(text.data.textFormat.color).toBe(0x00000000); expect(text.data.textFormatRanges[0].format.color).toBe(0xff0000ff); }); it('omits hidden tspan content while permitting a visible descendant override', () => { const root = createScene2DFromSvgDocument( 'ABC' + 'DEF', ); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.text).toBe('ACF'); }); it('composes nested tspan opacity without applying the text opacity twice', () => { const root = createScene2DFromSvgDocument( 'ABC', ); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.text).toBe('ABC'); expect(text.alpha).toBeCloseTo(0.8); expect(text.data.textFormat.color).toBe(0x000000ff); expect(text.data.textFormatRanges).toEqual([ expect.objectContaining({ end: 2, format: expect.objectContaining({ color: 0x00000080 }), start: 1 }), expect.objectContaining({ end: 3, format: expect.objectContaining({ color: 0x00000040 }), start: 2 }), ]); }); it('suppresses display none while allowing visibility descendants to override', () => { const root = createScene2DFromSvgDocument(` `); const displayGroup = getNodeChildAt(root, 0)!; const visibilityGroup = getNodeChildAt(root, 1)!; expect(displayGroup.visible).toBe(false); expect(getNodeChildAt(displayGroup, 0)?.visible).toBe(true); expect(visibilityGroup.visible).toBe(true); expect(getNodeChildAt(visibilityGroup, 0)?.visible).toBe(false); expect(getNodeChildAt(visibilityGroup, 1)?.visible).toBe(true); }); it('keeps display at its initial value on descendants of a display-none ancestor', () => { const root = createScene2DFromSvgDocument(` `); const group = getNodeChildAt(root, 0)!; expect(group.visible).toBe(false); expect(getNodeChildAt(group, 0)?.visible).toBe(true); }); it.each([ { kind: 'shape', path: [0], target: '' }, { kind: 'image', path: [0], target: '' }, { kind: 'text', path: [0], target: 'T' }, { kind: 'group', path: [0], target: '' }, { kind: 'use', path: [0], target: '' }, { kind: 'symbol', path: [0, 0], target: '' }, { kind: 'nested-use', path: [0, 0], target: '' }, ])('suppresses display none for $kind', ({ path, target }) => { const image = createReadyImageResourceForTest(10, 10); const root = createScene2DFromSvgDocument( ` ${target} `, undefined, { resolveImageResource: () => image }, ); expect(getDescendant(root, path).visible).toBe(false); }); it('excludes hidden clip graphics and permits descendant visibility overrides', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 20, y: 0 }); }); it.each([ { expected: { scaleX: 4, scaleY: 2, x: 0 }, kind: 'root SVG', source: '', target: 'root', }, { expected: { scaleX: 2, scaleY: 3, x: 5 }, kind: 'nested SVG', source: '', target: 'child', }, { expected: { scaleX: 2, scaleY: 3, x: 0 }, kind: 'symbol viewport', source: '', target: 'symbol', }, ])('maps viewBox and viewport sizing for $kind', ({ expected, source, target }) => { const root = createScene2DFromSvgDocument(source); const node = target === 'root' ? root : target === 'child' ? getNodeChildAt(root, 0)! : getNodeChildAt(getNodeChildAt(root, 0)!, 0)!; expect(node).toMatchObject(expected); }); }); function findFirstShape(target: Node2D): Shape | null { if (target.kind === ShapeKind) return target as Shape; const count = getNodeChildCount(target); for (let index = 0; index < count; index++) { const child = getNodeChildAt(target, index) as Node2D | null; if (child === null) continue; const shape = findFirstShape(child); if (shape !== null) return shape; } return null; } function getShapeFillColor(shape: Shape): number | null { const index = shape.data.commands.indexOf('beginFill'); return index === -1 ? null : (shape.data.commands[index + 2] as number); } // Asserts the sentinel rather than reading `bounds.x` regardless. `getShapeBounds` returns false when it // could not compute a complete box, and a freshly created rectangle is already x=0 — so discarding the // return makes "bounds were never registered" and "bounds start at x=0" the SAME observation. That is // what once disguised a missing bounds registration as an ordinary arithmetic mismatch (expected 13, got // 10), sending the reader after the wrong quantity entirely. function getShapeLocalX(shape: Shape): number { const bounds = createRectangle(); expect(getShapeBounds(bounds, shape), 'shape bounds could not be computed').toBe(true); return bounds.x; } function getShapePathWinding(shape: Shape): unknown { const index = shape.data.commands.indexOf('drawPath'); return index === -1 ? null : shape.data.commands[index + 4]; } function getDescendant(root: Node2D, path: number[]): Node2D { let node = root; for (const index of path) node = getNodeChildAt(node, index) as Node2D; return node; } // `inherit` is legal on every presentation attribute and is what the W3C's own conformance suite // uses to test the color property. Read as a paint value it yields no fill and no stroke, which // silently deletes the element's geometry rather than reporting anything. describe('SVG inherit keyword', () => { it.each([ { declaration: 'fill="inherit"', from: 'fill="#ff0000"', name: 'fill' }, { declaration: 'style="fill:inherit"', from: 'fill="#ff0000"', name: 'fill through a style attribute' }, { declaration: 'stroke="inherit" fill="none" stroke-width="2"', from: 'stroke="#ff0000"', name: 'stroke' }, ])('takes the parent value for an inherited $name', ({ declaration, from }) => { const svg = ``; expect(firstShapeCommands(svg)).toContain(0xff0000ff); }); it('resolves currentColor against a color that itself inherits', () => { const svg = '' + ''; expect(firstShapeCommands(svg)).toContain(0x008000ff); }); // opacity is not inherited, so `inherit` is the one way to ask for the parent's value rather than // the initial 1. Dropping the declaration instead would silently render this fully opaque. it('takes the parent value for opacity, which does not inherit on its own', () => { const svg = '' + ''; expect(firstShape(svg).alpha).toBe(0.5); }); it('leaves an unparented inherit at the initial value rather than dropping the geometry', () => { const svg = ''; expect(firstShape(svg).alpha).toBe(1); expect(firstShapeCommands(svg).length).toBeGreaterThan(0); }); }); // An SVG document may declare markup as a general entity in its DOCTYPE's internal subset and expand // it by reference. Expansion happens in `@flighthq/xml` before the tree is built, so the importer sees // ordinary markup; this pins that the two spellings import identically. describe('SVG internal DTD entities', () => { const inner = ""; it('imports an entity-expanded document the same as the literal markup', () => { const literal = `${inner}${inner}`; const entity = `]>` + '&S;&S;'; expect(countSvgDrawPaths(entity)).toBe(countSvgDrawPaths(literal)); expect(countSvgDrawPaths(entity)).toBeGreaterThan(0); }); }); function countSvgDrawPaths(svg: string): number { const root = createScene2DFromSvgDocument(svg); let count = 0; const walk = (node: Node2D): void => { if (node.kind === ShapeKind) { for (const command of (node as Shape).data.commands) if (command === 'drawPath') count++; } for (let index = 0; index < getNodeChildCount(node); index++) walk(getNodeChildAt(node, index) as Node2D); }; walk(root); return count; } function firstShape(svg: string): Shape { const root = createScene2DFromSvgDocument(svg); let found: Shape | null = null; const walk = (node: Node2D): void => { if (found === null && node.kind === ShapeKind) found = node as Shape; for (let index = 0; index < getNodeChildCount(node); index++) walk(getNodeChildAt(node, index) as Node2D); }; walk(root); if (found === null) throw new Error('no shape was imported'); return found; } function firstShapeCommands(svg: string): readonly unknown[] { return firstShape(svg).data.commands; }