altium-toolkit 0.1.0 → 0.1.16

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 (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +21 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Renders recovered embedded PCB fonts as self-contained SVG font faces.
7
+ */
8
+ export class PcbEmbeddedFontFaceRenderer {
9
+ /**
10
+ * Builds self-contained SVG @font-face rules for recovered embedded fonts.
11
+ * @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }[]} embeddedFonts
12
+ * @returns {string}
13
+ */
14
+ static buildMarkup(embeddedFonts) {
15
+ const rules = (embeddedFonts || [])
16
+ .filter((font) => font?.name && font?.payloadBase64)
17
+ .map((font) => PcbEmbeddedFontFaceRenderer.#buildRule(font))
18
+
19
+ return rules.length ? '<style>' + rules.join('') + '</style>' : ''
20
+ }
21
+
22
+ /**
23
+ * Builds one CSS @font-face rule.
24
+ * @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }} font
25
+ * @returns {string}
26
+ */
27
+ static #buildRule(font) {
28
+ const family = PcbEmbeddedFontFaceRenderer.#escapeCssString(font.name)
29
+ const base64 = PcbEmbeddedFontFaceRenderer.#sanitizeBase64(
30
+ font.payloadBase64
31
+ )
32
+
33
+ return (
34
+ "@font-face{font-family: '" +
35
+ family +
36
+ "'; font-style: " +
37
+ PcbEmbeddedFontFaceRenderer.#fontStyleForFont(font) +
38
+ '; font-weight: ' +
39
+ PcbEmbeddedFontFaceRenderer.#fontWeightForFont(font) +
40
+ "; src: url('data:" +
41
+ PcbEmbeddedFontFaceRenderer.#fontMimeType(font) +
42
+ ';base64,' +
43
+ base64 +
44
+ "') format('" +
45
+ PcbEmbeddedFontFaceRenderer.#fontFormat(font) +
46
+ "');}"
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Resolves a CSS font-style value from embedded font metadata.
52
+ * @param {{ style?: string }} font
53
+ * @returns {'normal' | 'italic'}
54
+ */
55
+ static #fontStyleForFont(font) {
56
+ return /italic|oblique/iu.test(String(font.style || ''))
57
+ ? 'italic'
58
+ : 'normal'
59
+ }
60
+
61
+ /**
62
+ * Resolves a CSS font-weight value from embedded font metadata.
63
+ * @param {{ style?: string, metrics?: { weightClass?: number } }} font
64
+ * @returns {number}
65
+ */
66
+ static #fontWeightForFont(font) {
67
+ if (Number(font.metrics?.weightClass) >= 100) {
68
+ return Number(font.metrics.weightClass)
69
+ }
70
+
71
+ return /bold/iu.test(String(font.style || '')) ? 700 : 400
72
+ }
73
+
74
+ /**
75
+ * Resolves a CSS font source MIME type.
76
+ * @param {{ mimeType?: string, format?: string }} font
77
+ * @returns {string}
78
+ */
79
+ static #fontMimeType(font) {
80
+ if (font.mimeType) {
81
+ return PcbEmbeddedFontFaceRenderer.#escapeCssUrlToken(font.mimeType)
82
+ }
83
+
84
+ return font.format === 'opentype' ? 'font/otf' : 'font/ttf'
85
+ }
86
+
87
+ /**
88
+ * Resolves a CSS font source format label.
89
+ * @param {{ format?: string }} font
90
+ * @returns {'opentype' | 'truetype'}
91
+ */
92
+ static #fontFormat(font) {
93
+ return font.format === 'opentype' ? 'opentype' : 'truetype'
94
+ }
95
+
96
+ /**
97
+ * Escapes a string for use inside a single-quoted CSS string.
98
+ * @param {string | undefined} value
99
+ * @returns {string}
100
+ */
101
+ static #escapeCssString(value) {
102
+ return String(value || '')
103
+ .replace(/\\/gu, '\\\\')
104
+ .replace(/'/gu, "\\'")
105
+ .replace(/\r?\n/gu, ' ')
106
+ .replace(/</gu, '\\3C ')
107
+ }
108
+
109
+ /**
110
+ * Keeps a base64 font payload constrained to data-URI-safe characters.
111
+ * @param {string | undefined} value
112
+ * @returns {string}
113
+ */
114
+ static #sanitizeBase64(value) {
115
+ return String(value || '').replace(/[^A-Za-z0-9+/=]/gu, '')
116
+ }
117
+
118
+ /**
119
+ * Escapes a short CSS URL token.
120
+ * @param {string | undefined} value
121
+ * @returns {string}
122
+ */
123
+ static #escapeCssUrlToken(value) {
124
+ return String(value || '').replace(/[^A-Za-z0-9./+-]/gu, '')
125
+ }
126
+ }
@@ -13,12 +13,23 @@ export class PcbFootprintPrimitiveSelector {
13
13
  * @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
14
14
  * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
15
15
  * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
16
+ * @param {{ points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] | 'top' | 'bottom'} [regionsOrSide]
16
17
  * @param {'top' | 'bottom'} [side]
17
- * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
18
+ * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }}
18
19
  */
19
- static select(primitiveLayers, fills, tracks, arcs, side = 'top') {
20
+ static select(
21
+ primitiveLayers,
22
+ fills,
23
+ tracks,
24
+ arcs,
25
+ regionsOrSide = [],
26
+ side = 'top'
27
+ ) {
28
+ const requestedSide =
29
+ typeof regionsOrSide === 'string' ? regionsOrSide : side
30
+ const regions = Array.isArray(regionsOrSide) ? regionsOrSide : []
20
31
  const prioritizedLayerMatchers =
21
- PcbFootprintPrimitiveSelector.#resolveLayerMatchers(side)
32
+ PcbFootprintPrimitiveSelector.#resolveLayerMatchers(requestedSide)
22
33
 
23
34
  for (const matchesLayerName of prioritizedLayerMatchers) {
24
35
  const layerIds = new Set(
@@ -41,12 +52,21 @@ export class PcbFootprintPrimitiveSelector {
41
52
  const layerArcs = (arcs || []).filter((arc) =>
42
53
  layerIds.has(arc.layerId)
43
54
  )
55
+ const layerRegions = regions.filter((region) =>
56
+ layerIds.has(region.layerId)
57
+ )
44
58
 
45
- if (layerFills.length || layerTracks.length || layerArcs.length) {
59
+ if (
60
+ layerFills.length ||
61
+ layerTracks.length ||
62
+ layerArcs.length ||
63
+ layerRegions.length
64
+ ) {
46
65
  return {
47
66
  fills: layerFills,
48
67
  tracks: layerTracks,
49
- arcs: layerArcs
68
+ arcs: layerArcs,
69
+ regions: layerRegions
50
70
  }
51
71
  }
52
72
  }
@@ -54,7 +74,8 @@ export class PcbFootprintPrimitiveSelector {
54
74
  return {
55
75
  fills: [],
56
76
  tracks: [],
57
- arcs: []
77
+ arcs: [],
78
+ regions: []
58
79
  }
59
80
  }
60
81
 
@@ -0,0 +1,243 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+
7
+ /**
8
+ * Renders filled PCB region contours into SVG path markup.
9
+ */
10
+ export class PcbRegionPrimitiveRenderer {
11
+ /**
12
+ * Builds SVG path markup for filled PCB regions.
13
+ * @param {{ points?: object[], holes?: object[][] }[]} regions
14
+ * @param {string} className
15
+ * @returns {string}
16
+ */
17
+ static buildMarkup(regions, className) {
18
+ return (regions || [])
19
+ .map((region) =>
20
+ PcbRegionPrimitiveRenderer.#renderRegion(region, className)
21
+ )
22
+ .join('')
23
+ }
24
+
25
+ /**
26
+ * Returns true when one region intersects a bounds object.
27
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
28
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
29
+ * @returns {boolean}
30
+ */
31
+ static intersectsBounds(region, bounds) {
32
+ const regionBounds = PcbRegionPrimitiveRenderer.bounds(region)
33
+ if (!regionBounds) {
34
+ return false
35
+ }
36
+
37
+ return !(
38
+ regionBounds.maxX < bounds.minX ||
39
+ regionBounds.minX > bounds.maxX ||
40
+ regionBounds.maxY < bounds.minY ||
41
+ regionBounds.minY > bounds.maxY
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Computes a bounding box for one filled region.
47
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
48
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
49
+ */
50
+ static bounds(region) {
51
+ const points = [
52
+ ...(region?.points || []),
53
+ ...(region?.holes || []).flat()
54
+ ].filter(
55
+ (point) => Number.isFinite(point?.x) && Number.isFinite(point?.y)
56
+ )
57
+
58
+ if (!points.length) {
59
+ return null
60
+ }
61
+
62
+ return {
63
+ minX: Math.min(...points.map((point) => Number(point.x))),
64
+ minY: Math.min(...points.map((point) => Number(point.y))),
65
+ maxX: Math.max(...points.map((point) => Number(point.x))),
66
+ maxY: Math.max(...points.map((point) => Number(point.y)))
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Pushes one region's extent into viewBox coordinate arrays.
72
+ * @param {number[]} xs
73
+ * @param {number[]} ys
74
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
75
+ */
76
+ static pushExtents(xs, ys, region) {
77
+ const bounds = PcbRegionPrimitiveRenderer.bounds(region)
78
+ if (!bounds) {
79
+ return
80
+ }
81
+
82
+ xs.push(bounds.minX, bounds.maxX)
83
+ ys.push(bounds.minY, bounds.maxY)
84
+ }
85
+
86
+ /**
87
+ * Renders one filled region path.
88
+ * @param {{ points?: object[], holes?: object[][] }} region
89
+ * @param {string} className
90
+ * @returns {string}
91
+ */
92
+ static #renderRegion(region, className) {
93
+ const path = PcbRegionPrimitiveRenderer.#buildRegionPath(region)
94
+ if (!path) {
95
+ return ''
96
+ }
97
+
98
+ return (
99
+ '<path class="' +
100
+ SchematicSvgUtils.escapeHtml(className) +
101
+ '" d="' +
102
+ SchematicSvgUtils.escapeHtml(path) +
103
+ '" fill-rule="evenodd" />'
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Builds one SVG path containing the outline and holes.
109
+ * @param {{ points?: object[], holes?: object[][] }} region
110
+ * @returns {string}
111
+ */
112
+ static #buildRegionPath(region) {
113
+ const paths = [
114
+ PcbRegionPrimitiveRenderer.#buildPointPath(region?.points || [])
115
+ ]
116
+
117
+ for (const hole of region?.holes || []) {
118
+ paths.push(PcbRegionPrimitiveRenderer.#buildPointPath(hole))
119
+ }
120
+
121
+ return paths.filter(Boolean).join(' ')
122
+ }
123
+
124
+ /**
125
+ * Builds one closed contour path from region points.
126
+ * @param {object[]} points
127
+ * @returns {string}
128
+ */
129
+ static #buildPointPath(points) {
130
+ const contour =
131
+ PcbRegionPrimitiveRenderer.#withoutClosingDuplicate(points)
132
+ if (contour.length < 3) {
133
+ return ''
134
+ }
135
+
136
+ const [first] = contour
137
+ const commands = [
138
+ 'M ' +
139
+ SchematicSvgUtils.formatNumber(first.x) +
140
+ ' ' +
141
+ SchematicSvgUtils.formatNumber(first.y)
142
+ ]
143
+
144
+ for (let index = 0; index < contour.length - 1; index += 1) {
145
+ const current = contour[index]
146
+ const next = contour[index + 1]
147
+ commands.push(
148
+ PcbRegionPrimitiveRenderer.#segmentCommand(current, next)
149
+ )
150
+ }
151
+
152
+ const last = contour[contour.length - 1]
153
+ if (PcbRegionPrimitiveRenderer.#isArcPoint(last)) {
154
+ commands.push(
155
+ PcbRegionPrimitiveRenderer.#segmentCommand(last, first)
156
+ )
157
+ }
158
+
159
+ commands.push('Z')
160
+ return commands.join(' ')
161
+ }
162
+
163
+ /**
164
+ * Builds one line or arc segment command.
165
+ * @param {object} current
166
+ * @param {object} next
167
+ * @returns {string}
168
+ */
169
+ static #segmentCommand(current, next) {
170
+ if (PcbRegionPrimitiveRenderer.#isArcPoint(current)) {
171
+ const delta =
172
+ PcbRegionPrimitiveRenderer.#normalizeAngle(
173
+ Number(current.endAngle || 0) -
174
+ Number(current.startAngle || 0)
175
+ ) || 360
176
+ return (
177
+ 'A ' +
178
+ SchematicSvgUtils.formatNumber(current.radius) +
179
+ ' ' +
180
+ SchematicSvgUtils.formatNumber(current.radius) +
181
+ ' 0 ' +
182
+ (delta > 180 ? '1' : '0') +
183
+ ' ' +
184
+ (Number(current.endAngle || 0) >=
185
+ Number(current.startAngle || 0)
186
+ ? '1'
187
+ : '0') +
188
+ ' ' +
189
+ SchematicSvgUtils.formatNumber(next.x) +
190
+ ' ' +
191
+ SchematicSvgUtils.formatNumber(next.y)
192
+ )
193
+ }
194
+
195
+ return (
196
+ 'L ' +
197
+ SchematicSvgUtils.formatNumber(next.x) +
198
+ ' ' +
199
+ SchematicSvgUtils.formatNumber(next.y)
200
+ )
201
+ }
202
+
203
+ /**
204
+ * Removes an explicit duplicate closing vertex when present.
205
+ * @param {object[]} points
206
+ * @returns {object[]}
207
+ */
208
+ static #withoutClosingDuplicate(points) {
209
+ if ((points || []).length < 2) {
210
+ return points || []
211
+ }
212
+
213
+ const first = points[0]
214
+ const last = points[points.length - 1]
215
+ if (
216
+ Math.abs(Number(first.x) - Number(last.x)) < 1e-6 &&
217
+ Math.abs(Number(first.y) - Number(last.y)) < 1e-6
218
+ ) {
219
+ return points.slice(0, -1)
220
+ }
221
+
222
+ return points
223
+ }
224
+
225
+ /**
226
+ * Checks whether one region point represents an arc segment.
227
+ * @param {object | undefined} point
228
+ * @returns {boolean}
229
+ */
230
+ static #isArcPoint(point) {
231
+ return Boolean(point?.isArc && Number(point.radius || 0) > 0)
232
+ }
233
+
234
+ /**
235
+ * Normalizes one angle delta into [0, 360).
236
+ * @param {number} angle
237
+ * @returns {number}
238
+ */
239
+ static #normalizeAngle(angle) {
240
+ const normalized = Number(angle || 0) % 360
241
+ return normalized < 0 ? normalized + 360 : normalized
242
+ }
243
+ }