circuit-to-canvas 0.0.1

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.
Files changed (38) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/README.md +30 -0
  3. package/biome.json +93 -0
  4. package/bunfig.toml +6 -0
  5. package/dist/index.d.ts +151 -0
  6. package/dist/index.js +375 -0
  7. package/lib/drawer/CircuitToCanvasDrawer.ts +119 -0
  8. package/lib/drawer/elements/index.ts +4 -0
  9. package/lib/drawer/elements/pcb-plated-hole.ts +168 -0
  10. package/lib/drawer/index.ts +5 -0
  11. package/lib/drawer/shapes/circle.ts +23 -0
  12. package/lib/drawer/shapes/index.ts +4 -0
  13. package/lib/drawer/shapes/oval.ts +34 -0
  14. package/lib/drawer/shapes/pill.ts +60 -0
  15. package/lib/drawer/shapes/rect.ts +69 -0
  16. package/lib/drawer/types.ts +125 -0
  17. package/lib/index.ts +1 -0
  18. package/lib/pcb/index.ts +5 -0
  19. package/package.json +25 -0
  20. package/tests/__snapshots__/svg.snap.svg +3 -0
  21. package/tests/elements/__snapshots__/oval-plated-hole.snap.png +0 -0
  22. package/tests/elements/__snapshots__/pcb-plated-hole.snap.png +0 -0
  23. package/tests/elements/__snapshots__/pill-plated-hole.snap.png +0 -0
  24. package/tests/elements/pcb-plated-hole.test.ts +90 -0
  25. package/tests/fixtures/png-matcher.ts +159 -0
  26. package/tests/fixtures/preload.ts +2 -0
  27. package/tests/shapes/__snapshots__/circle.snap.png +0 -0
  28. package/tests/shapes/__snapshots__/oval.snap.png +0 -0
  29. package/tests/shapes/__snapshots__/pill-vertical.snap.png +0 -0
  30. package/tests/shapes/__snapshots__/pill.snap.png +0 -0
  31. package/tests/shapes/__snapshots__/rect-rounded.snap.png +0 -0
  32. package/tests/shapes/__snapshots__/rect.snap.png +0 -0
  33. package/tests/shapes/circle.test.ts +24 -0
  34. package/tests/shapes/oval.test.ts +25 -0
  35. package/tests/shapes/pill.test.ts +47 -0
  36. package/tests/shapes/rect.test.ts +48 -0
  37. package/tests/svg.test.ts +11 -0
  38. package/tsconfig.json +31 -0
@@ -0,0 +1,90 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbPlatedHole } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw circular plated hole", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+ const drawer = new CircuitToCanvasDrawer(ctx)
10
+
11
+ ctx.fillStyle = "#1a1a1a"
12
+ ctx.fillRect(0, 0, 100, 100)
13
+
14
+ const hole: PcbPlatedHole = {
15
+ type: "pcb_plated_hole",
16
+ pcb_plated_hole_id: "hole1",
17
+ shape: "circle",
18
+ x: 50,
19
+ y: 50,
20
+ outer_diameter: 50,
21
+ hole_diameter: 25,
22
+ layers: ["top", "bottom"],
23
+ }
24
+
25
+ drawer.drawElements([hole])
26
+
27
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
28
+ import.meta.path,
29
+ )
30
+ })
31
+
32
+ test("draw oval plated hole", async () => {
33
+ const canvas = createCanvas(100, 100)
34
+ const ctx = canvas.getContext("2d")
35
+ const drawer = new CircuitToCanvasDrawer(ctx)
36
+
37
+ ctx.fillStyle = "#1a1a1a"
38
+ ctx.fillRect(0, 0, 100, 100)
39
+
40
+ const hole: PcbPlatedHole = {
41
+ type: "pcb_plated_hole",
42
+ pcb_plated_hole_id: "hole1",
43
+ shape: "oval",
44
+ x: 50,
45
+ y: 50,
46
+ outer_width: 60,
47
+ outer_height: 40,
48
+ hole_width: 40,
49
+ hole_height: 20,
50
+ layers: ["top", "bottom"],
51
+ ccw_rotation: 0,
52
+ }
53
+
54
+ drawer.drawElements([hole])
55
+
56
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
57
+ import.meta.path,
58
+ "oval-plated-hole",
59
+ )
60
+ })
61
+
62
+ test("draw pill plated hole", async () => {
63
+ const canvas = createCanvas(100, 100)
64
+ const ctx = canvas.getContext("2d")
65
+ const drawer = new CircuitToCanvasDrawer(ctx)
66
+
67
+ ctx.fillStyle = "#1a1a1a"
68
+ ctx.fillRect(0, 0, 100, 100)
69
+
70
+ const hole: PcbPlatedHole = {
71
+ type: "pcb_plated_hole",
72
+ pcb_plated_hole_id: "hole1",
73
+ shape: "pill",
74
+ x: 50,
75
+ y: 50,
76
+ outer_width: 70,
77
+ outer_height: 35,
78
+ hole_width: 50,
79
+ hole_height: 20,
80
+ layers: ["top", "bottom"],
81
+ ccw_rotation: 0,
82
+ }
83
+
84
+ drawer.drawElements([hole])
85
+
86
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
87
+ import.meta.path,
88
+ "pill-plated-hole",
89
+ )
90
+ })
@@ -0,0 +1,159 @@
1
+ import { expect, type MatcherResult } from "bun:test"
2
+ import * as fs from "node:fs"
3
+ import * as path from "node:path"
4
+ import looksSame from "looks-same"
5
+
6
+ /**
7
+ * Matcher for PNG snapshot testing with cross-platform tolerance.
8
+ *
9
+ * Usage:
10
+ * expect(pngBuffer).toMatchPngSnapshot(import.meta.path, "optionalName");
11
+ */
12
+ async function toMatchPngSnapshot(
13
+ // biome-ignore lint/suspicious/noExplicitAny: bun doesn't expose
14
+ this: any,
15
+ receivedMaybePromise: Buffer | Uint8Array | Promise<Buffer | Uint8Array>,
16
+ testPathOriginal: string,
17
+ pngName?: string,
18
+ ): Promise<MatcherResult> {
19
+ const received = await receivedMaybePromise
20
+ const testPath = testPathOriginal
21
+ .replace(/\.test\.tsx?$/, "")
22
+ .replace(/\.test\.ts$/, "")
23
+ const snapshotDir = path.join(path.dirname(testPath), "__snapshots__")
24
+ const snapshotName = pngName
25
+ ? `${pngName}.snap.png`
26
+ : `${path.basename(testPath)}.snap.png`
27
+ const filePath = path.join(snapshotDir, snapshotName)
28
+
29
+ if (!fs.existsSync(snapshotDir)) {
30
+ fs.mkdirSync(snapshotDir, { recursive: true })
31
+ }
32
+
33
+ const updateSnapshot =
34
+ process.argv.includes("--update-snapshots") ||
35
+ process.argv.includes("-u") ||
36
+ Boolean(process.env["BUN_UPDATE_SNAPSHOTS"])
37
+ const forceUpdate = Boolean(process.env["FORCE_BUN_UPDATE_SNAPSHOTS"])
38
+
39
+ const fileExists = fs.existsSync(filePath)
40
+
41
+ if (!fileExists) {
42
+ console.log("Writing PNG snapshot to", filePath)
43
+ fs.writeFileSync(filePath, received)
44
+ return {
45
+ message: () => `PNG snapshot created at ${filePath}`,
46
+ pass: true,
47
+ }
48
+ }
49
+
50
+ const existingSnapshot = fs.readFileSync(filePath)
51
+
52
+ const result: any = await looksSame(
53
+ Buffer.from(received),
54
+ Buffer.from(existingSnapshot),
55
+ {
56
+ strict: false,
57
+ tolerance: 5,
58
+ antialiasingTolerance: 4,
59
+ ignoreCaret: true,
60
+ shouldCluster: true,
61
+ clustersSize: 10,
62
+ },
63
+ )
64
+
65
+ if (updateSnapshot) {
66
+ if (!forceUpdate && result.equal) {
67
+ return {
68
+ message: () => "PNG snapshot matches",
69
+ pass: true,
70
+ }
71
+ }
72
+ console.log("Updating PNG snapshot at", filePath)
73
+ fs.writeFileSync(filePath, received)
74
+ return {
75
+ message: () => `PNG snapshot updated at ${filePath}`,
76
+ pass: true,
77
+ }
78
+ }
79
+
80
+ if (result.equal) {
81
+ return {
82
+ message: () => "PNG snapshot matches",
83
+ pass: true,
84
+ }
85
+ }
86
+
87
+ // Calculate diff percentage for cross-platform tolerance
88
+ if (result.diffBounds) {
89
+ // Get image dimensions from the PNG buffer
90
+ const width = existingSnapshot.readUInt32BE(16)
91
+ const height = existingSnapshot.readUInt32BE(20)
92
+ const totalPixels = width * height
93
+
94
+ const diffArea =
95
+ (result.diffBounds.right - result.diffBounds.left) *
96
+ (result.diffBounds.bottom - result.diffBounds.top)
97
+ const diffPercentage = (diffArea / totalPixels) * 100
98
+
99
+ // Allow up to 5% pixel difference for cross-platform rendering variations
100
+ const ACCEPTABLE_DIFF_PERCENTAGE = 5.0
101
+
102
+ if (diffPercentage <= ACCEPTABLE_DIFF_PERCENTAGE) {
103
+ console.log(
104
+ `✓ PNG snapshot matches (${diffPercentage.toFixed(3)}% difference, within ${ACCEPTABLE_DIFF_PERCENTAGE}% threshold)`,
105
+ )
106
+ return {
107
+ message: () =>
108
+ `PNG snapshot matches (${diffPercentage.toFixed(3)}% difference)`,
109
+ pass: true,
110
+ }
111
+ }
112
+
113
+ // If difference is too large, create diff image
114
+ const diffPath = filePath.replace(/\.snap\.png$/, ".diff.png")
115
+ await looksSame.createDiff({
116
+ reference: Buffer.from(existingSnapshot),
117
+ current: Buffer.from(received),
118
+ diff: diffPath,
119
+ highlightColor: "#ff00ff",
120
+ })
121
+
122
+ return {
123
+ message: () =>
124
+ `PNG snapshot differs by ${diffPercentage.toFixed(3)}% (threshold: ${ACCEPTABLE_DIFF_PERCENTAGE}%). Diff saved at ${diffPath}. Use BUN_UPDATE_SNAPSHOTS=1 to update the snapshot.`,
125
+ pass: false,
126
+ }
127
+ }
128
+
129
+ // Fallback if diffBounds isn't available
130
+ const diffPath = filePath.replace(/\.snap\.png$/, ".diff.png")
131
+ await looksSame.createDiff({
132
+ reference: Buffer.from(existingSnapshot),
133
+ current: Buffer.from(received),
134
+ diff: diffPath,
135
+ highlightColor: "#ff00ff",
136
+ })
137
+
138
+ console.log(`📸 Snapshot mismatch (no diff bounds available)`)
139
+ console.log(` Diff saved: ${diffPath}`)
140
+
141
+ return {
142
+ message: () => `PNG snapshot does not match. Diff saved at ${diffPath}`,
143
+ pass: false,
144
+ }
145
+ }
146
+
147
+ // Register the matcher globally for Bun's expect
148
+ expect.extend({
149
+ toMatchPngSnapshot: toMatchPngSnapshot as any,
150
+ })
151
+
152
+ declare module "bun:test" {
153
+ interface Matchers<T = unknown> {
154
+ toMatchPngSnapshot(
155
+ testPath: string,
156
+ pngName?: string,
157
+ ): Promise<MatcherResult>
158
+ }
159
+ }
@@ -0,0 +1,2 @@
1
+ import "bun-match-svg"
2
+ import "./png-matcher"
@@ -0,0 +1,24 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import { identity } from "transformation-matrix"
4
+ import { drawCircle } from "../../lib/drawer/shapes/circle"
5
+
6
+ test("draw circle", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 100, 100)
12
+
13
+ drawCircle({
14
+ ctx,
15
+ center: { x: 50, y: 50 },
16
+ radius: 30,
17
+ fill: "#ff0000",
18
+ transform: identity(),
19
+ })
20
+
21
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
22
+ import.meta.path,
23
+ )
24
+ })
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import { identity } from "transformation-matrix"
4
+ import { drawOval } from "../../lib/drawer/shapes/oval"
5
+
6
+ test("draw oval", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 100, 100)
12
+
13
+ drawOval({
14
+ ctx,
15
+ center: { x: 50, y: 50 },
16
+ width: 70,
17
+ height: 40,
18
+ fill: "#0000ff",
19
+ transform: identity(),
20
+ })
21
+
22
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
23
+ import.meta.path,
24
+ )
25
+ })
@@ -0,0 +1,47 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import { identity } from "transformation-matrix"
4
+ import { drawPill } from "../../lib/drawer/shapes/pill"
5
+
6
+ test("draw horizontal pill", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 100, 100)
12
+
13
+ drawPill({
14
+ ctx,
15
+ center: { x: 50, y: 50 },
16
+ width: 70,
17
+ height: 30,
18
+ fill: "#ff00ff",
19
+ transform: identity(),
20
+ })
21
+
22
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
23
+ import.meta.path,
24
+ )
25
+ })
26
+
27
+ test("draw vertical pill", async () => {
28
+ const canvas = createCanvas(100, 100)
29
+ const ctx = canvas.getContext("2d")
30
+
31
+ ctx.fillStyle = "#1a1a1a"
32
+ ctx.fillRect(0, 0, 100, 100)
33
+
34
+ drawPill({
35
+ ctx,
36
+ center: { x: 50, y: 50 },
37
+ width: 30,
38
+ height: 70,
39
+ fill: "#ff00ff",
40
+ transform: identity(),
41
+ })
42
+
43
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
44
+ import.meta.path,
45
+ "pill-vertical",
46
+ )
47
+ })
@@ -0,0 +1,48 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import { identity } from "transformation-matrix"
4
+ import { drawRect } from "../../lib/drawer/shapes/rect"
5
+
6
+ test("draw rect", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 100, 100)
12
+
13
+ drawRect({
14
+ ctx,
15
+ center: { x: 50, y: 50 },
16
+ width: 60,
17
+ height: 40,
18
+ fill: "#00ff00",
19
+ transform: identity(),
20
+ })
21
+
22
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
23
+ import.meta.path,
24
+ )
25
+ })
26
+
27
+ test("draw rect with border radius", async () => {
28
+ const canvas = createCanvas(100, 100)
29
+ const ctx = canvas.getContext("2d")
30
+
31
+ ctx.fillStyle = "#1a1a1a"
32
+ ctx.fillRect(0, 0, 100, 100)
33
+
34
+ drawRect({
35
+ ctx,
36
+ center: { x: 50, y: 50 },
37
+ width: 60,
38
+ height: 40,
39
+ fill: "#00ff00",
40
+ transform: identity(),
41
+ borderRadius: 10,
42
+ })
43
+
44
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
45
+ import.meta.path,
46
+ "rect-rounded",
47
+ )
48
+ })
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test"
2
+
3
+ const testSvg = `<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
4
+ <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
5
+ </svg>`
6
+
7
+ test("svg snapshot example", async () => {
8
+ // First run will create the snapshot
9
+ // Subsequent runs will compare against the saved snapshot
10
+ await expect(testSvg).toMatchSvgSnapshot(import.meta.path)
11
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ },
29
+ "include": ["lib/**/*", "tests/**/*"],
30
+ "exclude": ["node_modules", "circuit-to-svg"]
31
+ }