altium-toolkit 1.2.1 → 1.3.0

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.
package/README.md CHANGED
@@ -32,6 +32,13 @@ Version 1.2.1 keeps Three.js as an example-only development dependency. The
32
32
  published parser and CircuitJSON services do not install a second, unused
33
33
  Three.js runtime into host applications.
34
34
 
35
+ Version 1.3.0 updates the common runtime baseline to CircuitJSON Toolkit 1.2
36
+ and adapts effectively invisible historical 32-bit BMP previews at the common
37
+ convergence boundary. The public parser and renderer now show the existing
38
+ missing-image placeholder for those unusable payloads without changing the
39
+ frozen native parser or healthy embedded images. See the
40
+ [1.3.0 release notes](docs/release-notes-v1.3.0.md).
41
+
35
42
  Default `extensions: 'canonical'` keeps compact Altium summary metadata.
36
43
  Request the complete native read model with `extensions: 'full'`,
37
44
  `preserveRaw: true`, or `extensions: ['altium.native-model']`. Project batches
@@ -199,6 +206,7 @@ const legacyCircuitJson = AltiumParser.parseArrayBuffer(file.name, arrayBuffer)
199
206
  - [API](docs/api.md)
200
207
  - [Capabilities](docs/capabilities.md)
201
208
  - [Migration from 1.1.41](docs/migration.md)
209
+ - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
202
210
  - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
203
211
  - [1.2.0 release notes](docs/release-notes-v1.2.0.md)
204
212
  - [Model Format](docs/model-format.md)
@@ -60,6 +60,16 @@ the object form can call
60
60
  `AltiumParser.parseArrayBufferToRendererModel(fileName, arrayBuffer)` or
61
61
  `CircuitJsonModelAdapter.toRendererModel(circuitJson)`.
62
62
 
63
+ The common parser and schematic renderer expose a convergence-only view of
64
+ historical embedded image rows. A parser-generated RGBA PNG recovered from a
65
+ 32-bit BMP is marked `diagnosticState: 'unusable-embedded-payload'` only when
66
+ its decoded alpha coverage is strictly below one percent. Its placement,
67
+ source MIME type, file name, and aspect metadata remain intact while drawable
68
+ payload fields are cleared so the established missing-image placeholder is
69
+ rendered. Native PNG, JPEG, GIF, SVG, and WebP payloads, malformed or unknown
70
+ PNG encodings, and images at or above the threshold are not rewritten. The
71
+ retained historical parser model itself remains unchanged.
72
+
63
73
  ## Common Fields
64
74
 
65
75
  - `schema`: normalized model schema id, currently
@@ -0,0 +1,312 @@
1
+ # Missing Schematic Image Placeholder Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Render the exact Altium missing-file message when a recovered 32-bit BMP preview is effectively invisible.
6
+
7
+ **Architecture:** `SchematicImageParser` remains responsible for deciding whether recovered binary image data is drawable. It will measure 32-bit BMP alpha coverage and expose an unusable payload through the existing normalized image contract, allowing `SchematicImageRenderer` to reuse its established placeholder markup without binary inspection.
8
+
9
+ **Tech Stack:** JavaScript ES modules, Node.js test runner through `npm test`, OLE schematic test factory, SVG string renderer.
10
+
11
+ ## Global Constraints
12
+
13
+ - Classify only from image payload structure and visibility; never match file names, paths, projects, or source-derived phrases.
14
+ - Alpha coverage below 1 percent is effectively invisible.
15
+ - Wrapped native PNG, JPEG, GIF, SVG, and WebP payloads remain unchanged.
16
+ - Preserve image placement, source path, embed flag, aspect ratio, and render order for unusable payloads.
17
+ - Use only generated, obfuscated schematic data in tests.
18
+ - Do not add a provided native `.SchDoc` file to the test suite.
19
+
20
+ ---
21
+
22
+ ### Task 1: Add the failing parser-to-renderer regression
23
+
24
+ **Files:**
25
+
26
+ - Modify: `tests/core/altium-parser/schematic-images.mjs`
27
+
28
+ **Interfaces:**
29
+
30
+ - Consumes: `AltiumParser.parseArrayBufferToRendererModel(fileName, arrayBuffer)` and `SchematicSvgRenderer.render(documentModel)`.
31
+ - Produces: `createSparseAlphaBmpBytes()` and height support in `createBmpBytes(options)` for generated test payloads.
32
+
33
+ - [ ] **Step 1: Import the schematic renderer**
34
+
35
+ ```js
36
+ import { SchematicSvgRenderer } from '../../../src/ui/SchematicSvgRenderer.mjs'
37
+ ```
38
+
39
+ - [ ] **Step 2: Add a generated sparse-alpha regression**
40
+
41
+ ```js
42
+ test('parseAltiumArrayBuffer renders effectively invisible BMP previews as missing-image placeholders', () => {
43
+ const imageFileName = 'C:\\Forge\\Obfuscated\\Artwork\\ghost-badge.bmp'
44
+ const fileHeaderText =
45
+ '|HEADER=Schematic Document' +
46
+ '|RECORD=31|CustomX=160|CustomY=120|VisibleGridSize=10|SnapGridSize=5' +
47
+ '|BorderOn=F|TitleBlockOn=F|CustomMarginWidth=10|CustomXZones=6|CustomYZones=4' +
48
+ '|FontIdCount=1|Size1=10|FontName1=Times New Roman|Bold1=F|Rotation1=0' +
49
+ '|RECORD=30|IndexInSheet=2|Location.X=20|Location.Y=30|Corner.X=80|Corner.Y=70' +
50
+ '|EmbedImage=T|KeepAspect=T|FileName=' +
51
+ imageFileName
52
+ const arrayBuffer = SchematicImageOleFactory.createDocumentBuffer({
53
+ fileHeaderText,
54
+ imageFileName,
55
+ imageBytes: createSparseAlphaBmpBytes()
56
+ })
57
+ const documentModel = AltiumParser.parseArrayBufferToRendererModel(
58
+ 'ghost-image.SchDoc',
59
+ arrayBuffer
60
+ )
61
+ const image = documentModel.schematic.images[0]
62
+ const markup = SchematicSvgRenderer.render(documentModel)
63
+
64
+ assert.equal(image.diagnosticState, 'unusable-embedded-payload')
65
+ assert.equal(image.mimeType, '')
66
+ assert.equal(image.dataBase64, '')
67
+ assert.match(
68
+ documentModel.diagnostics
69
+ .map((diagnostic) => diagnostic.message)
70
+ .join('\n'),
71
+ /effectively invisible/i
72
+ )
73
+ assert.match(markup, /Cannot open file/)
74
+ assert.match(markup, /C:\\Forge\\Obfuscated/)
75
+ assert.match(markup, /ghost-badge\.bmp/)
76
+ assert.match(markup, /\. File does not exist\./)
77
+ assert.doesNotMatch(markup, /class="schematic-embedded-image/)
78
+ })
79
+ ```
80
+
81
+ - [ ] **Step 3: Extend the BMP factory without adding fixture files**
82
+
83
+ ```js
84
+ function createSparseAlphaBmpBytes() {
85
+ const width = 20
86
+ const height = 20
87
+ const pixels = new Array(width * height * 4).fill(0)
88
+ pixels.splice(0, 4, 0xff, 0xff, 0xff, 0xff)
89
+
90
+ return createBmpBytes({ bitsPerPixel: 32, width, height, pixels })
91
+ }
92
+ ```
93
+
94
+ Change the factory contract and height initialization to:
95
+
96
+ ```js
97
+ /**
98
+ * @param {{ bitsPerPixel: 24 | 32, width?: number, height?: number, pixels: number[] }} options
99
+ */
100
+ function createBmpBytes(options) {
101
+ const width = options.width || 1
102
+ const height = options.height || 1
103
+ ```
104
+
105
+ - [ ] **Step 4: Run the focused regression and verify RED**
106
+
107
+ Run:
108
+
109
+ ```bash
110
+ npm test -- --test-name-pattern="effectively invisible BMP previews"
111
+ ```
112
+
113
+ Expected: FAIL because the image currently has diagnostic state `embedded` and non-empty PNG data.
114
+
115
+ ---
116
+
117
+ ### Task 2: Classify effectively invisible previews in the parser
118
+
119
+ **Files:**
120
+
121
+ - Modify: `src/core/altium/SchematicImageParser.mjs`
122
+ - Test: `tests/core/altium-parser/schematic-images.mjs`
123
+
124
+ **Interfaces:**
125
+
126
+ - Consumes: parsed BMP metadata `{ width, height, bitsPerPixel, pixelOffset, rowStride }`.
127
+ - Produces: decoded payload field `effectivelyInvisible: boolean`, normalized diagnostic state `unusable-embedded-payload`, and a recoverable warning.
128
+
129
+ - [ ] **Step 1: Add the structural coverage threshold**
130
+
131
+ ```js
132
+ const MINIMUM_VISIBLE_ALPHA_COVERAGE = 0.01
133
+ ```
134
+
135
+ - [ ] **Step 2: Return invisibility metadata from payload decoding**
136
+
137
+ Update `#decodeEmbeddedImagePayload` so native payloads return
138
+ `effectivelyInvisible: false`. For BMP previews, calculate coverage before PNG
139
+ encoding:
140
+
141
+ ```js
142
+ const alphaCoverage = SchematicImageParser.#bmpAlphaCoverage(bytes, bmpInfo)
143
+
144
+ if (alphaCoverage !== null && alphaCoverage < MINIMUM_VISIBLE_ALPHA_COVERAGE) {
145
+ return {
146
+ bytes: new Uint8Array(),
147
+ mimeType: '',
148
+ sourceMimeType: sourceMimeType || 'image/bmp',
149
+ nativeClass: '',
150
+ hasAlpha: true,
151
+ effectivelyInvisible: true
152
+ }
153
+ }
154
+
155
+ if (alphaCoverage !== null && alphaCoverage < 1) {
156
+ const rgba = SchematicImageParser.#decodeBmpRgba(bytes, bmpInfo)
157
+ return {
158
+ bytes: SchematicImageParser.#encodePngRgba(
159
+ bmpInfo.width,
160
+ bmpInfo.height,
161
+ rgba
162
+ ),
163
+ mimeType: PNG_SCHEMA_MIME_TYPE,
164
+ sourceMimeType: sourceMimeType || 'image/bmp',
165
+ nativeClass: '',
166
+ hasAlpha: true,
167
+ effectivelyInvisible: false
168
+ }
169
+ }
170
+ ```
171
+
172
+ The unchanged raw-payload return also includes
173
+ `effectivelyInvisible: false`.
174
+
175
+ - [ ] **Step 3: Replace the boolean alpha scan with coverage measurement**
176
+
177
+ ```js
178
+ static #bmpAlphaCoverage(bytes, bmpInfo) {
179
+ if (bmpInfo?.bitsPerPixel !== 32) return null
180
+
181
+ let alphaTotal = 0
182
+ for (let y = 0; y < bmpInfo.height; y += 1) {
183
+ const rowOffset = bmpInfo.pixelOffset + y * bmpInfo.rowStride
184
+ for (let x = 0; x < bmpInfo.width; x += 1) {
185
+ alphaTotal += bytes[rowOffset + x * 4 + 3]
186
+ }
187
+ }
188
+
189
+ return alphaTotal / (bmpInfo.width * bmpInfo.height * 255)
190
+ }
191
+ ```
192
+
193
+ Add complete JSDoc describing the nullable coverage result.
194
+
195
+ - [ ] **Step 4: Normalize the unusable payload without dropping placement**
196
+
197
+ In `#parseSchematicImageRecord`, branch on
198
+ `decoded.effectivelyInvisible` before assigning drawable data:
199
+
200
+ ```js
201
+ sourceMimeType = decoded.sourceMimeType
202
+ nativeClass = decoded.nativeClass
203
+ hasAlpha = decoded.hasAlpha
204
+
205
+ if (decoded.effectivelyInvisible) {
206
+ diagnosticState = 'unusable-embedded-payload'
207
+ diagnostics.push({
208
+ severity: 'warning',
209
+ message:
210
+ 'Embedded schematic image payload is effectively invisible for ' +
211
+ fileName +
212
+ '.'
213
+ })
214
+ } else {
215
+ mimeType = decoded.mimeType
216
+ dataBase64 = SchematicImageParser.#encodeBase64(decoded.bytes)
217
+ diagnosticState = 'embedded'
218
+ }
219
+ ```
220
+
221
+ - [ ] **Step 5: Run the focused regression and verify GREEN**
222
+
223
+ Run:
224
+
225
+ ```bash
226
+ npm test -- --test-name-pattern="effectively invisible BMP previews"
227
+ ```
228
+
229
+ Expected: PASS, including exact placeholder-message assertions.
230
+
231
+ - [ ] **Step 6: Run the complete library verification**
232
+
233
+ Run:
234
+
235
+ ```bash
236
+ npm test
237
+ npm run check:format
238
+ ```
239
+
240
+ Expected: all tests pass and Prettier reports all matched files use its formatting.
241
+
242
+ - [ ] **Step 7: Commit the parser fix and regression**
243
+
244
+ ```bash
245
+ git add src/core/altium/SchematicImageParser.mjs tests/core/altium-parser/schematic-images.mjs
246
+ git commit -m "fix: render unusable schematic images as placeholders"
247
+ ```
248
+
249
+ ---
250
+
251
+ ### Task 3: Verify the ECAD Forge integration
252
+
253
+ **Files:**
254
+
255
+ - Verify only: `/Users/afiedler/Documents/privat/Andrés_Werkstatt/ecadforge_app`
256
+
257
+ **Interfaces:**
258
+
259
+ - Consumes: local `altium-toolkit` package source and the ECAD Forge Altium demo URL.
260
+ - Produces: library/app test evidence plus live DOM and screenshot evidence.
261
+
262
+ - [ ] **Step 1: Install the local library into the app without changing manifests**
263
+
264
+ Run from the ECAD Forge repository:
265
+
266
+ ```bash
267
+ npm install --no-save --package-lock=false ../altium-toolkit
268
+ ```
269
+
270
+ Expected: `package.json` and `package-lock.json` remain unchanged.
271
+
272
+ - [ ] **Step 2: Run the app-owned tests**
273
+
274
+ ```bash
275
+ npm test
276
+ ```
277
+
278
+ Expected: all ECAD Forge tests pass.
279
+
280
+ - [ ] **Step 3: Reopen the local demo and inspect observable markup**
281
+
282
+ Open:
283
+
284
+ ```text
285
+ http://localhost:3000/?demo=altium&view=schematic&document=NODEMCU_ESP12.SchDoc
286
+ ```
287
+
288
+ Expected DOM evidence:
289
+
290
+ ```text
291
+ .schematic-image-placeholder count: 1
292
+ .schematic-image-placeholder-message text includes:
293
+ Cannot open file
294
+ C:\Forge\Obfuscated\Artwork\ghost-badge.bmp
295
+ . File does not exist.
296
+ .schematic-embedded-image count: 0
297
+ ```
298
+
299
+ - [ ] **Step 4: Capture and inspect a screenshot**
300
+
301
+ Expected: the missing-file message is visible in the authored title-block image
302
+ bounds and the surrounding schematic/title-block geometry remains unchanged.
303
+
304
+ - [ ] **Step 5: Confirm both worktrees contain only intended tracked changes**
305
+
306
+ ```bash
307
+ git status --short
308
+ git -C ../altium-toolkit status --short
309
+ ```
310
+
311
+ Expected: ECAD Forge has no tracked changes; Altium Toolkit is clean after its
312
+ implementation commit.
@@ -0,0 +1,30 @@
1
+ # altium-toolkit 1.3.0
2
+
3
+ Version 1.3.0 consumes the CircuitJSON Toolkit 1.2 shared contract and restores
4
+ the established schematic placeholder for unusable embedded image previews.
5
+
6
+ ## Schematic image behavior
7
+
8
+ - The common `Parser` and `SchematicSvgRenderer` adapt the exact historical
9
+ BMP-to-PNG representation when its decoded alpha coverage is strictly below
10
+ one percent. Those rows use `unusable-embedded-payload`, preserve placement
11
+ and source metadata, and render the existing missing-image placeholder.
12
+ - The adapter is owned by the convergence layer. The frozen 1.1.41 native
13
+ parser and its extension API remain byte-identical and continue to expose the
14
+ original recovered payload.
15
+ - Native wrapped PNG, JPEG, GIF, SVG, and WebP assets are unchanged. Malformed,
16
+ unknown, differently encoded, and exactly one-percent-visible PNG data is
17
+ also left untouched. Historical stored-zlib payloads must pass both the PNG
18
+ chunk CRC and the zlib Adler-32 checksum before adaptation.
19
+ - Only affected image rows and their containing model branches are cloned.
20
+ Alpha coverage is cached by immutable image identity, and warnings are
21
+ deduplicated.
22
+
23
+ ## Shared runtime
24
+
25
+ - `circuitjson-toolkit` now uses the `^1.2.0` runtime baseline, aligning Altium
26
+ documents with the validated PCB text, drilled-pad geometry, and metadata
27
+ consumed by the other source toolkits and the 3D viewer.
28
+ - Existing root exports, package subpaths, parser parameters, document/project
29
+ envelopes, native extension APIs, and healthy image return shapes remain
30
+ available. The unusable-image state is an additive common-view behavior.
package/docs/testing.md CHANGED
@@ -27,10 +27,13 @@ npm run check:format
27
27
  npm pack --dry-run
28
28
  ```
29
29
 
30
- The strict feature check creates an isolated packed install, installs the
31
- approved CircuitJSON 1.1.0 release candidate, verifies all historical native
32
- source and extension contracts, checks the exact package/subpath layout, and
33
- runs the shared observable toolkit contract against the packed package.
30
+ The strict feature check creates an isolated packed install and packs the
31
+ currently installed CircuitJSON dependency beside the Altium candidate. This
32
+ keeps the isolated contract gate aligned with the dependency declared by the
33
+ active release instead of a stale version-specific fixture. It verifies all
34
+ historical native source and extension contracts, checks the exact
35
+ package/subpath layout, and runs the shared observable toolkit contract against
36
+ the packed package.
34
37
 
35
38
  The performance check is bound to the immutable 1.1.41 commit, source tree,
36
39
  and native-source manifest. It measures legacy and canonical projections of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -67,7 +67,7 @@
67
67
  "check:features": "node scripts/check-feature-preservation.mjs"
68
68
  },
69
69
  "dependencies": {
70
- "circuitjson-toolkit": "^1.1.0",
70
+ "circuitjson-toolkit": "^1.2.0",
71
71
  "fflate": "^0.8.2"
72
72
  },
73
73
  "devDependencies": {
@@ -0,0 +1,82 @@
1
+ # Missing Schematic Image Placeholder Design
2
+
3
+ ## Problem
4
+
5
+ An embedded schematic image record can resolve to a 32-bit BMP preview whose
6
+ alpha channel makes nearly the entire payload invisible. The parser currently
7
+ treats any mixture of transparent and visible pixels as meaningful alpha,
8
+ converts the preview to PNG, and classifies it as an embedded image. The SVG
9
+ renderer then emits an image node that appears blank instead of the missing-file
10
+ message used for an unavailable image.
11
+
12
+ ## Scope
13
+
14
+ The fix belongs in `altium-toolkit`, which owns Altium image parsing and
15
+ schematic SVG rendering. It must apply to all recovered 32-bit BMP previews
16
+ without matching source file names, paths, projects, or message content.
17
+
18
+ Native wrapped PNG, JPEG, GIF, SVG, and WebP payloads remain unchanged. Healthy
19
+ BMP previews with meaningful visible alpha coverage also remain embedded.
20
+
21
+ ## Design
22
+
23
+ `SchematicImageParser` will measure the alpha coverage of an uncompressed
24
+ 32-bit BMP preview before converting it to PNG. Alpha coverage is the sum of
25
+ the pixel alpha values divided by the maximum possible alpha sum for the image.
26
+
27
+ A preview with alpha coverage below 1 percent is effectively invisible and is
28
+ not a drawable embedded payload. The normalized image retains its placement,
29
+ source file name, embed flag, aspect-ratio flag, and render order, but exposes
30
+ empty `mimeType` and `dataBase64` values with diagnostic state
31
+ `unusable-embedded-payload`. Parsing also emits a warning that the embedded
32
+ payload is effectively invisible.
33
+
34
+ The existing `SchematicImageRenderer` already renders images without usable
35
+ payload data as placeholders. It will therefore produce the existing exact
36
+ Altium-style message:
37
+
38
+ 1. `Cannot open file`
39
+ 2. The source path wrapped to the placeholder width
40
+ 3. `. File does not exist.`
41
+
42
+ No renderer-specific binary inspection or source-specific phrase matching will
43
+ be added.
44
+
45
+ ## Data Flow
46
+
47
+ 1. Resolve the embedded image bytes from an OLE stream or packed storage.
48
+ 2. Prefer a valid wrapped native image payload when present.
49
+ 3. For an uncompressed 32-bit BMP preview, calculate alpha coverage.
50
+ 4. Classify coverage below 1 percent as unusable; otherwise keep the current
51
+ BMP-to-PNG behavior.
52
+ 5. Render unusable payloads through the existing missing-image placeholder.
53
+
54
+ ## Error Handling
55
+
56
+ An effectively invisible preview is a recoverable parse condition. The parser
57
+ keeps the image record and adds a warning rather than throwing or dropping the
58
+ placement. Missing payloads continue to use their current diagnostic state.
59
+
60
+ ## Testing
61
+
62
+ Tests will use only generated, obfuscated schematic data:
63
+
64
+ - A synthetic OLE schematic with a 32-bit BMP preview below 1 percent alpha
65
+ coverage must normalize to `unusable-embedded-payload` with empty payload
66
+ data and a warning.
67
+ - Rendering that normalized image must contain the exact three-part message and
68
+ wrapped source path, with no embedded image node.
69
+ - A synthetic 32-bit BMP with alpha coverage at or above 1 percent must remain
70
+ a PNG embedded image.
71
+ - Existing `altium-toolkit` tests and the ECAD Forge application tests must
72
+ remain green.
73
+ - The local ECAD Forge demo must show the placeholder message in the authored
74
+ image bounds.
75
+
76
+ ## Acceptance Criteria
77
+
78
+ - Effectively invisible recovered BMP previews display the exact Altium-style
79
+ missing-file message.
80
+ - Valid embedded images continue to render.
81
+ - The behavior is derived only from payload structure and visibility.
82
+ - No provided native schematic is added to the test suite.
@@ -7,6 +7,7 @@ import { AltiumParser } from '../core/altium/AltiumParser.mjs'
7
7
  import { CircuitJsonModelAdapter } from '../core/circuit-json/CircuitJsonModelAdapter.mjs'
8
8
  import { CircuitJsonSchematicImageProjection } from '../core/circuit-json/CircuitJsonSchematicImageProjection.mjs'
9
9
  import { AltiumCircuitJsonProjection } from './AltiumCircuitJsonProjection.mjs'
10
+ import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
10
11
  import { ParserInput } from './ParserInput.mjs'
11
12
 
12
13
  /**
@@ -20,9 +21,11 @@ export class AltiumDocumentBuilder {
20
21
  */
21
22
  static decode(normalized) {
22
23
  const buffer = ParserInput.arrayBuffer(normalized.input.data)
23
- const native = AltiumParser.parseArrayBufferToRendererModel(
24
- normalized.input.fileName,
25
- buffer
24
+ const native = AltiumSchematicImageNormalizer.normalize(
25
+ AltiumParser.parseArrayBufferToRendererModel(
26
+ normalized.input.fileName,
27
+ buffer
28
+ )
26
29
  )
27
30
  const adapted = CircuitJsonModelAdapter.fromRendererModel(native)
28
31
  const projected = AltiumCircuitJsonProjection.project(adapted, native)
@@ -0,0 +1,404 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { unzlibSync } from 'fflate'
5
+
6
+ const MAX_SCANLINE_BYTES = 64 * 1024 * 1024
7
+ const MINIMUM_VISIBLE_ALPHA_COVERAGE = 0.01
8
+ const PNG_SIGNATURE = Uint8Array.from([
9
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
10
+ ])
11
+
12
+ /**
13
+ * Adapts historical native schematic-image payloads at the common API boundary.
14
+ */
15
+ export class AltiumSchematicImageNormalizer {
16
+ static #alphaCoverageCache = new WeakMap()
17
+
18
+ /**
19
+ * Returns a structurally shared document view with unusable historical
20
+ * image payloads replaced by the existing missing-image state.
21
+ * @param {Record<string, any>} documentModel Native renderer document.
22
+ * @returns {Record<string, any>} Original document or normalized view.
23
+ */
24
+ static normalize(documentModel) {
25
+ const images = documentModel?.schematic?.images
26
+ if (!Array.isArray(images) || !images.length) return documentModel
27
+
28
+ const diagnostics = Array.isArray(documentModel.diagnostics)
29
+ ? documentModel.diagnostics
30
+ : []
31
+ let normalizedImages = null
32
+ const warnings = []
33
+
34
+ for (let index = 0; index < images.length; index += 1) {
35
+ const image = images[index]
36
+ if (
37
+ !AltiumSchematicImageNormalizer.#isInvisibleLegacyImage(image)
38
+ ) {
39
+ continue
40
+ }
41
+
42
+ if (!normalizedImages) normalizedImages = images.slice()
43
+ normalizedImages[index] = {
44
+ ...image,
45
+ mimeType: '',
46
+ dataBase64: '',
47
+ diagnosticState: 'unusable-embedded-payload'
48
+ }
49
+
50
+ const warning =
51
+ 'Embedded schematic image payload is effectively invisible for ' +
52
+ (String(image.fileName || '') || 'unnamed image') +
53
+ '.'
54
+ if (
55
+ !AltiumSchematicImageNormalizer.#hasWarning(
56
+ diagnostics,
57
+ warning
58
+ ) &&
59
+ !warnings.some((diagnostic) => diagnostic.message === warning)
60
+ ) {
61
+ warnings.push({ severity: 'warning', message: warning })
62
+ }
63
+ }
64
+
65
+ if (!normalizedImages) return documentModel
66
+
67
+ return {
68
+ ...documentModel,
69
+ schematic: {
70
+ ...documentModel.schematic,
71
+ images: normalizedImages
72
+ },
73
+ diagnostics: warnings.length
74
+ ? [...diagnostics, ...warnings]
75
+ : documentModel.diagnostics
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Returns true only for the precise historical BMP-to-PNG representation
81
+ * whose decoded alpha coverage is below one percent.
82
+ * @param {Record<string, any>} image Native schematic-image row.
83
+ * @returns {boolean} Whether the payload should become a placeholder.
84
+ */
85
+ static #isInvisibleLegacyImage(image) {
86
+ if (
87
+ !image ||
88
+ typeof image !== 'object' ||
89
+ String(image.sourceMimeType || '').toLowerCase() !== 'image/bmp' ||
90
+ String(image.mimeType || '').toLowerCase() !== 'image/png' ||
91
+ image.hasAlpha !== true ||
92
+ String(image.nativeClass || '') !== '' ||
93
+ typeof image.dataBase64 !== 'string' ||
94
+ !image.dataBase64
95
+ ) {
96
+ return false
97
+ }
98
+
99
+ const alphaCoverage =
100
+ AltiumSchematicImageNormalizer.#historicalPngAlphaCoverage(image)
101
+ return (
102
+ alphaCoverage !== null &&
103
+ alphaCoverage < MINIMUM_VISIBLE_ALPHA_COVERAGE
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Decodes a historical parser-generated PNG and measures normalized alpha.
109
+ * Unknown, malformed, or differently encoded PNG payloads are rejected.
110
+ * @param {Record<string, any>} image Native schematic-image row.
111
+ * @returns {number | null} Normalized alpha coverage or null.
112
+ */
113
+ static #historicalPngAlphaCoverage(image) {
114
+ const cached =
115
+ AltiumSchematicImageNormalizer.#alphaCoverageCache.get(image)
116
+ if (cached?.dataBase64 === image.dataBase64) return cached.coverage
117
+
118
+ const coverage =
119
+ AltiumSchematicImageNormalizer.#decodeHistoricalPngAlphaCoverage(
120
+ image.dataBase64
121
+ )
122
+ AltiumSchematicImageNormalizer.#alphaCoverageCache.set(image, {
123
+ dataBase64: image.dataBase64,
124
+ coverage
125
+ })
126
+ return coverage
127
+ }
128
+
129
+ /**
130
+ * Parses the exact minimal RGBA8 PNG structure emitted by the historical
131
+ * parser and computes its alpha coverage.
132
+ * @param {string} dataBase64 Base64-encoded PNG bytes.
133
+ * @returns {number | null} Normalized alpha coverage or null.
134
+ */
135
+ static #decodeHistoricalPngAlphaCoverage(dataBase64) {
136
+ const png = AltiumSchematicImageNormalizer.#decodeBase64(dataBase64)
137
+ if (!png || !AltiumSchematicImageNormalizer.#hasPngSignature(png)) {
138
+ return null
139
+ }
140
+
141
+ const parsed = AltiumSchematicImageNormalizer.#parseHistoricalPng(png)
142
+ if (!parsed) return null
143
+
144
+ const scanlineLength = parsed.width * 4 + 1
145
+ const expectedLength = scanlineLength * parsed.height
146
+ if (
147
+ !Number.isSafeInteger(expectedLength) ||
148
+ expectedLength <= 0 ||
149
+ expectedLength > MAX_SCANLINE_BYTES ||
150
+ !AltiumSchematicImageNormalizer.#isHistoricalStoredZlib(
151
+ parsed.idat,
152
+ expectedLength
153
+ )
154
+ ) {
155
+ return null
156
+ }
157
+
158
+ let raw
159
+ try {
160
+ raw = unzlibSync(parsed.idat, {
161
+ out: new Uint8Array(expectedLength)
162
+ })
163
+ } catch {
164
+ return null
165
+ }
166
+ if (
167
+ raw.length !== expectedLength ||
168
+ !AltiumSchematicImageNormalizer.#hasValidAdler32(parsed.idat, raw)
169
+ ) {
170
+ return null
171
+ }
172
+
173
+ let alphaTotal = 0
174
+ for (let y = 0; y < parsed.height; y += 1) {
175
+ const rowOffset = y * scanlineLength
176
+ if (raw[rowOffset] !== 0) return null
177
+ for (let x = 0; x < parsed.width; x += 1) {
178
+ alphaTotal += raw[rowOffset + 1 + x * 4 + 3]
179
+ }
180
+ }
181
+ return alphaTotal / (parsed.width * parsed.height * 255)
182
+ }
183
+
184
+ /**
185
+ * Parses an exact IHDR, IDAT, IEND PNG sequence and validates chunk CRCs.
186
+ * @param {Uint8Array} png PNG bytes.
187
+ * @returns {{ width: number, height: number, idat: Uint8Array } | null} Parsed PNG facts.
188
+ */
189
+ static #parseHistoricalPng(png) {
190
+ const chunks = []
191
+ let offset = PNG_SIGNATURE.length
192
+
193
+ while (offset + 12 <= png.length) {
194
+ const view = new DataView(
195
+ png.buffer,
196
+ png.byteOffset + offset,
197
+ png.length - offset
198
+ )
199
+ const length = view.getUint32(0, false)
200
+ const chunkEnd = offset + 12 + length
201
+ if (chunkEnd > png.length) return null
202
+
203
+ const typeBytes = png.subarray(offset + 4, offset + 8)
204
+ const type = String.fromCharCode(...typeBytes)
205
+ const data = png.subarray(offset + 8, offset + 8 + length)
206
+ const expectedCrc = new DataView(
207
+ png.buffer,
208
+ png.byteOffset + offset + 8 + length,
209
+ 4
210
+ ).getUint32(0, false)
211
+ if (
212
+ AltiumSchematicImageNormalizer.#crc32(typeBytes, data) !==
213
+ expectedCrc
214
+ ) {
215
+ return null
216
+ }
217
+
218
+ chunks.push({ type, data })
219
+ offset = chunkEnd
220
+ if (type === 'IEND') break
221
+ }
222
+
223
+ if (
224
+ offset !== png.length ||
225
+ chunks.length !== 3 ||
226
+ chunks[0].type !== 'IHDR' ||
227
+ chunks[1].type !== 'IDAT' ||
228
+ chunks[2].type !== 'IEND' ||
229
+ chunks[0].data.length !== 13 ||
230
+ chunks[2].data.length !== 0
231
+ ) {
232
+ return null
233
+ }
234
+
235
+ const header = chunks[0].data
236
+ const headerView = new DataView(
237
+ header.buffer,
238
+ header.byteOffset,
239
+ header.byteLength
240
+ )
241
+ const width = headerView.getUint32(0, false)
242
+ const height = headerView.getUint32(4, false)
243
+ if (
244
+ width <= 0 ||
245
+ height <= 0 ||
246
+ header[8] !== 8 ||
247
+ header[9] !== 6 ||
248
+ header[10] !== 0 ||
249
+ header[11] !== 0 ||
250
+ header[12] !== 0
251
+ ) {
252
+ return null
253
+ }
254
+
255
+ return { width, height, idat: chunks[1].data }
256
+ }
257
+
258
+ /**
259
+ * Validates the zlib stored-block layout emitted by the historical parser
260
+ * before allocating or inflating its payload.
261
+ * @param {Uint8Array} zlib Zlib stream.
262
+ * @param {number} expectedLength Expected raw byte count.
263
+ * @returns {boolean} Whether the stream has the exact safe stored layout.
264
+ */
265
+ static #isHistoricalStoredZlib(zlib, expectedLength) {
266
+ if (zlib.length < 11 || zlib[0] !== 0x78 || zlib[1] !== 0x01) {
267
+ return false
268
+ }
269
+
270
+ const payloadEnd = zlib.length - 4
271
+ let offset = 2
272
+ let decodedLength = 0
273
+ let finalBlock = false
274
+
275
+ while (!finalBlock && offset + 5 <= payloadEnd) {
276
+ const header = zlib[offset]
277
+ if (header !== 0x00 && header !== 0x01) return false
278
+ finalBlock = header === 0x01
279
+
280
+ const length = zlib[offset + 1] | (zlib[offset + 2] << 8)
281
+ const complement = zlib[offset + 3] | (zlib[offset + 4] << 8)
282
+ if ((length ^ complement) !== 0xffff) return false
283
+
284
+ offset += 5
285
+ if (offset + length > payloadEnd) return false
286
+ decodedLength += length
287
+ if (decodedLength > expectedLength) return false
288
+ offset += length
289
+ }
290
+
291
+ return (
292
+ finalBlock &&
293
+ offset === payloadEnd &&
294
+ decodedLength === expectedLength
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Validates decompressed bytes against the zlib stream's big-endian
300
+ * Adler-32 trailer.
301
+ * @param {Uint8Array} zlib Complete zlib stream.
302
+ * @param {Uint8Array} raw Decompressed bytes.
303
+ * @returns {boolean} Whether the checksum is valid.
304
+ */
305
+ static #hasValidAdler32(zlib, raw) {
306
+ if (!(zlib instanceof Uint8Array) || zlib.length < 4) return false
307
+ const trailerOffset = zlib.length - 4
308
+ const expected = new DataView(
309
+ zlib.buffer,
310
+ zlib.byteOffset + trailerOffset,
311
+ 4
312
+ ).getUint32(0, false)
313
+ return AltiumSchematicImageNormalizer.#adler32(raw) === expected
314
+ }
315
+
316
+ /**
317
+ * Computes an Adler-32 checksum without external runtime dependencies.
318
+ * @param {Uint8Array} bytes Bytes to checksum.
319
+ * @returns {number} Unsigned Adler-32 value.
320
+ */
321
+ static #adler32(bytes) {
322
+ let a = 1
323
+ let b = 0
324
+ let offset = 0
325
+
326
+ while (offset < bytes.length) {
327
+ const end = Math.min(offset + 5552, bytes.length)
328
+ for (; offset < end; offset += 1) {
329
+ a += bytes[offset]
330
+ b += a
331
+ }
332
+ a %= 65521
333
+ b %= 65521
334
+ }
335
+
336
+ return ((b << 16) | a) >>> 0
337
+ }
338
+
339
+ /**
340
+ * Decodes base64 without depending on Node-only globals.
341
+ * @param {string} dataBase64 Base64 text.
342
+ * @returns {Uint8Array | null} Decoded bytes or null.
343
+ */
344
+ static #decodeBase64(dataBase64) {
345
+ if (dataBase64.length > Math.ceil((MAX_SCANLINE_BYTES * 4) / 3) + 128) {
346
+ return null
347
+ }
348
+ try {
349
+ const binary = globalThis.atob(dataBase64)
350
+ const bytes = new Uint8Array(binary.length)
351
+ for (let index = 0; index < binary.length; index += 1) {
352
+ bytes[index] = binary.charCodeAt(index)
353
+ }
354
+ return bytes
355
+ } catch {
356
+ return null
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Checks the fixed PNG signature.
362
+ * @param {Uint8Array} bytes Candidate bytes.
363
+ * @returns {boolean} Whether the signature matches.
364
+ */
365
+ static #hasPngSignature(bytes) {
366
+ if (bytes.length < PNG_SIGNATURE.length) return false
367
+ return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
368
+ }
369
+
370
+ /**
371
+ * Computes a PNG chunk CRC over its type and data bytes.
372
+ * @param {Uint8Array} typeBytes Four-byte chunk type.
373
+ * @param {Uint8Array} data Chunk data.
374
+ * @returns {number} Unsigned CRC-32.
375
+ */
376
+ static #crc32(typeBytes, data) {
377
+ let crc = 0xffffffff
378
+ for (const bytes of [typeBytes, data]) {
379
+ for (const byte of bytes) {
380
+ crc ^= byte
381
+ for (let bit = 0; bit < 8; bit += 1) {
382
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
383
+ }
384
+ }
385
+ }
386
+ return (crc ^ 0xffffffff) >>> 0
387
+ }
388
+
389
+ /**
390
+ * Checks whether a warning already exists without changing diagnostic rows.
391
+ * @param {Record<string, any>[] | undefined} diagnostics Native diagnostics.
392
+ * @param {string} warning Warning text.
393
+ * @returns {boolean} Whether the warning already exists.
394
+ */
395
+ static #hasWarning(diagnostics, warning) {
396
+ return (
397
+ Array.isArray(diagnostics) &&
398
+ diagnostics.some((diagnostic) => diagnostic?.message === warning)
399
+ )
400
+ }
401
+ }
402
+
403
+ Object.freeze(AltiumSchematicImageNormalizer.prototype)
404
+ Object.freeze(AltiumSchematicImageNormalizer)
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import { SchematicSvgRenderer as LegacySchematicSvgRenderer } from '../ui/SchematicSvgRenderer.mjs'
5
+ import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
5
6
 
6
7
  /**
7
8
  * Renders native Altium schematic models through the preserved historical
@@ -17,8 +18,10 @@ export class SchematicSvgRenderer {
17
18
  * @returns {string} Rendered SVG panel markup.
18
19
  */
19
20
  static render(documentModel, options = {}) {
21
+ const normalized =
22
+ AltiumSchematicImageNormalizer.normalize(documentModel)
20
23
  return LegacySchematicSvgRenderer.render(
21
- SchematicSvgRenderer.#visibilityAwareDocument(documentModel),
24
+ SchematicSvgRenderer.#visibilityAwareDocument(normalized),
22
25
  options
23
26
  )
24
27
  }