CI Log in

zach/dotter

test/svg.test.js 121 lines · 5.0 KB · javascript
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
});