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(
`
`,
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(
`
`,
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(
`
`,
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(
`
`,
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(
`
`,
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(`
`);
const shape = findFirstShape(getNodeChildAt(root, 0)!)!;
expect(getShapeFillColor(shape)).toBe(expected);
});
it('applies stylesheet cascade to text and tspan runs', () => {
const root = createScene2DFromSvgDocument(`
`);
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('');
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(
'',
);
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(
'',
);
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(
`
`,
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;
}