| 1 | import { describe, expect, test } from 'bun:test';
|
| 2 | import { apply, documentFrame, joinPolylines, lengthToInches, parsePathData, parseTransform,
|
| 3 | shapeToPathData, svgToPolylines } from '../src/svg.js';
|
| 4 | import { distanceToPolylines } from './helpers.js';
|
| 5 |
|
| 6 | /** Minimal element stand-in so the SVG walker can be tested without a DOM. */
|
| 7 | function el(localName, attrs = {}, children = []) {
|
| 8 | return {
|
| 9 | localName,
|
| 10 | children,
|
| 11 | getAttribute: (k) => (k in attrs ? String(attrs[k]) : null),
|
| 12 | hasAttribute: (k) => k in attrs,
|
| 13 | };
|
| 14 | }
|
| 15 |
|
| 16 | describe('lengths and transforms', () => {
|
| 17 | test('units', () => {
|
| 18 | expect(lengthToInches('8.5in')).toBe(8.5);
|
| 19 | expect(lengthToInches('25.4mm')).toBeCloseTo(1, 12);
|
| 20 | expect(lengthToInches('2.54cm')).toBeCloseTo(1, 12);
|
| 21 | expect(lengthToInches('96')).toBe(1);
|
| 22 | expect(lengthToInches('72pt')).toBe(1);
|
| 23 | expect(lengthToInches('100%')).toBe(null);
|
| 24 | expect(lengthToInches(null)).toBe(null);
|
| 25 | });
|
| 26 |
|
| 27 | test('transform lists compose left to right', () => {
|
| 28 | const m = parseTransform('translate(10, 5) scale(2) rotate(90)');
|
| 29 | const [x, y] = apply(m, [1, 0]);
|
| 30 | expect(x).toBeCloseTo(10, 12);
|
| 31 | expect(y).toBeCloseTo(7, 12);
|
| 32 | const r = parseTransform('rotate(180 5 5)');
|
| 33 | expect(apply(r, [0, 0]).map((v) => Math.round(v * 1e9) / 1e9)).toEqual([10, 10]);
|
| 34 | expect(parseTransform('matrix(1,0,0,1,3,4)')).toEqual([1, 0, 0, 1, 3, 4]);
|
| 35 | });
|
| 36 | });
|
| 37 |
|
| 38 | describe('path data', () => {
|
| 39 | test('lines, relative commands, implicit linetos, close', () => {
|
| 40 | const subs = parsePathData('M10 10 l 5 0 0 5 H 10 z m 20 0 h 1');
|
| 41 | expect(subs[0]).toEqual([[10, 10], [15, 10], [15, 15], [10, 15], [10, 10]]);
|
| 42 | expect(subs[1]).toEqual([[30, 10], [31, 10]]);
|
| 43 | });
|
| 44 |
|
| 45 | test('cubic flattening stays within tolerance', () => {
|
| 46 | const tol = 0.01;
|
| 47 | const [pts] = parsePathData('M0,0 C 0,10 10,10 10,0', tol);
|
| 48 | // B(t) for control points (0,0) (0,10) (10,10) (10,0)
|
| 49 | const bez = (t) => [3 * t * t * (1 - t) * 10 + t ** 3 * 10, 3 * t * (1 - t) ** 2 * 10 + 3 * t * t * (1 - t) * 10];
|
| 50 | for (let i = 0; i <= 100; i++) expect(distanceToPolylines(bez(i / 100), [pts])).toBeLessThan(tol * 1.5);
|
| 51 | expect(pts[pts.length - 1]).toEqual([10, 0]);
|
| 52 | });
|
| 53 |
|
| 54 | test('arcs: a semicircle', () => {
|
| 55 | const tol = 0.001;
|
| 56 | const [pts] = parsePathData('M 0 0 A 5 5 0 0 1 10 0', tol);
|
| 57 | for (const [x, y] of pts) expect(Math.abs(Math.hypot(x - 5, y) - 5)).toBeLessThan(tol * 1.01);
|
| 58 | expect(pts[pts.length - 1]).toEqual([10, 0]);
|
| 59 | // sweep=1 from (0,0) to (10,0) goes through negative y (SVG y points down)
|
| 60 | expect(Math.min(...pts.map((p) => p[1]))).toBeCloseTo(-5, 2);
|
| 61 | });
|
| 62 |
|
| 63 | test('quadratic and smooth curves', () => {
|
| 64 | const subs = parsePathData('M0 0 Q 5 10 10 0 T 20 0 M0 20 C 0 25 5 25 5 20 S 10 15 10 20');
|
| 65 | expect(subs.length).toBe(2);
|
| 66 | expect(subs[0][subs[0].length - 1]).toEqual([20, 0]);
|
| 67 | expect(subs[1][subs[1].length - 1]).toEqual([10, 20]);
|
| 68 | });
|
| 69 |
|
| 70 | test('garbage input does not throw', () => {
|
| 71 | expect(parsePathData('not a path')).toEqual([]);
|
| 72 | expect(parsePathData('M 1')).toEqual([]);
|
| 73 | });
|
| 74 | });
|
| 75 |
|
| 76 | describe('shapes and documents', () => {
|
| 77 | test('basic shapes become path data', () => {
|
| 78 | expect(shapeToPathData('rect', el('rect', { x: 1, y: 2, width: 3, height: 4 }))).toBe('M1,2H4V6H1Z');
|
| 79 | expect(shapeToPathData('line', el('line', { x1: 0, y1: 0, x2: 1, y2: 1 }))).toBe('M0,0 L1,1');
|
| 80 | expect(shapeToPathData('polygon', el('polygon', { points: '0,0 1,0 1,1' }))).toBe('M0,0 1,0 1,1Z');
|
| 81 | expect(shapeToPathData('circle', el('circle', { r: 0 }))).toBe(null);
|
| 82 | });
|
| 83 |
|
| 84 | test('viewBox maps user units to inches', () => {
|
| 85 | const f = documentFrame(el('svg', { width: '4in', height: '2in', viewBox: '0 0 400 200' }));
|
| 86 | expect(f.width).toBe(4);
|
| 87 | expect(apply(f.matrix, [400, 200])).toEqual([4, 2]);
|
| 88 | });
|
| 89 |
|
| 90 | test('document walk: nested transforms, hidden and defs skipped', () => {
|
| 91 | const doc = el('svg', { width: '10in', height: '5in', viewBox: '0 0 10 5' }, [
|
| 92 | el('g', { transform: 'translate(1,1)' }, [
|
| 93 | el('line', { x1: 0, y1: 0, x2: 2, y2: 0 }),
|
| 94 | el('rect', { x: 0, y: 0, width: 1, height: 1, style: 'display:none' }),
|
| 95 | ]),
|
| 96 | el('defs', {}, [el('line', { x1: 0, y1: 0, x2: 9, y2: 0 })]),
|
| 97 | ]);
|
| 98 | const { polylines, rotated } = svgToPolylines(doc);
|
| 99 | expect(rotated).toBe(false);
|
| 100 | expect(polylines).toEqual([[[1, 1], [3, 1]]]);
|
| 101 | });
|
| 102 |
|
| 103 | test('portrait pages rotate to fit the landscape travel', () => {
|
| 104 | const doc = el('svg', { width: '8.5in', height: '11in', viewBox: '0 0 8.5 11' }, [
|
| 105 | el('line', { x1: 0, y1: 0, x2: 0, y2: 11 }),
|
| 106 | ]);
|
| 107 | const r = svgToPolylines(doc);
|
| 108 | expect(r.rotated).toBe(true);
|
| 109 | expect([r.width, r.height]).toEqual([11, 8.5]);
|
| 110 | for (const [x, y] of r.polylines[0]) {
|
| 111 | expect(x).toBeGreaterThanOrEqual(0);
|
| 112 | expect(x).toBeLessThanOrEqual(11);
|
| 113 | expect(y).toBeCloseTo(8.5, 9);
|
| 114 | }
|
| 115 | });
|
| 116 |
|
| 117 | test('joinPolylines merges touching paths only', () => {
|
| 118 | const joined = joinPolylines([[[0, 0], [1, 0]], [[1, 0.001], [2, 0]], [[5, 5], [6, 6]]]);
|
| 119 | expect(joined).toEqual([[[0, 0], [1, 0], [2, 0]], [[5, 5], [6, 6]]]);
|
| 120 | });
|
| 121 | });
|