altium-toolkit 1.2.1 → 1.4.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 +15 -0
- package/docs/api.md +7 -0
- package/docs/model-format.md +10 -0
- package/docs/plans/2026-07-13-missing-image-placeholder-implementation.md +312 -0
- package/docs/release-notes-v1.3.0.md +30 -0
- package/docs/release-notes-v1.4.0.md +37 -0
- package/docs/testing.md +7 -4
- package/package.json +2 -2
- package/spec/2026-07-13-missing-image-placeholder-design.md +82 -0
- package/src/convergence/AltiumDocumentBuilder.mjs +7 -4
- package/src/convergence/AltiumSchematicImageNormalizer.mjs +404 -0
- package/src/convergence/SchematicSvgRenderer.mjs +4 -1
package/README.md
CHANGED
|
@@ -32,6 +32,19 @@ 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
|
+
|
|
42
|
+
Version 1.4.0 adopts newly decoded CircuitJSON and native extension graphs at
|
|
43
|
+
the shared validation boundary. Their ordinary nodes retain identity and are
|
|
44
|
+
deeply frozen without a redundant defensive graph copy. Parser parameters,
|
|
45
|
+
document envelopes, extension fields, and return shapes remain unchanged. See
|
|
46
|
+
the [1.4.0 release notes](docs/release-notes-v1.4.0.md).
|
|
47
|
+
|
|
35
48
|
Default `extensions: 'canonical'` keeps compact Altium summary metadata.
|
|
36
49
|
Request the complete native read model with `extensions: 'full'`,
|
|
37
50
|
`preserveRaw: true`, or `extensions: ['altium.native-model']`. Project batches
|
|
@@ -199,6 +212,8 @@ const legacyCircuitJson = AltiumParser.parseArrayBuffer(file.name, arrayBuffer)
|
|
|
199
212
|
- [API](docs/api.md)
|
|
200
213
|
- [Capabilities](docs/capabilities.md)
|
|
201
214
|
- [Migration from 1.1.41](docs/migration.md)
|
|
215
|
+
- [1.4.0 release notes](docs/release-notes-v1.4.0.md)
|
|
216
|
+
- [1.3.0 release notes](docs/release-notes-v1.3.0.md)
|
|
202
217
|
- [1.2.1 release notes](docs/release-notes-v1.2.1.md)
|
|
203
218
|
- [1.2.0 release notes](docs/release-notes-v1.2.0.md)
|
|
204
219
|
- [Model Format](docs/model-format.md)
|
package/docs/api.md
CHANGED
|
@@ -111,6 +111,13 @@ metadata. The complete recovered native read model remains available through
|
|
|
111
111
|
`document.model` and do not require the full native extension.
|
|
112
112
|
`extensions: 'none'` or `extensions: []` returns exactly `extensions: {}`.
|
|
113
113
|
|
|
114
|
+
The convergence builder transfers its newly decoded ordinary CircuitJSON and
|
|
115
|
+
native extension graphs into the shared owned-document validator. Eligible
|
|
116
|
+
graph nodes retain identity and are deeply frozen in place, avoiding a second
|
|
117
|
+
full defensive copy. This is an internal ownership optimization after source
|
|
118
|
+
decoding: raw caller data remains untrusted, and every public parser parameter,
|
|
119
|
+
validation rule, document field, and return shape is unchanged.
|
|
120
|
+
|
|
114
121
|
Canonical `.SchDoc` models preserve native drawing order, ownership, geometry,
|
|
115
122
|
and style as shared `schematic_rect`, `schematic_circle`, `schematic_arc`,
|
|
116
123
|
`schematic_path`, `schematic_text`, `schematic_table`, and
|
package/docs/model-format.md
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# altium-toolkit 1.4.0
|
|
2
|
+
|
|
3
|
+
Version 1.4.0 adopts parser-owned CircuitJSON and retained native extension
|
|
4
|
+
graphs through the CircuitJSON Toolkit 1.4 ownership boundary. It removes a
|
|
5
|
+
redundant defensive graph copy from successful document construction without
|
|
6
|
+
weakening validation or immutability.
|
|
7
|
+
|
|
8
|
+
## Owned convergence graphs
|
|
9
|
+
|
|
10
|
+
- The Altium convergence builder uses
|
|
11
|
+
`DocumentResult.createValidatedOwned(fields, runtime?)` for graphs it has
|
|
12
|
+
just constructed and exclusively owns.
|
|
13
|
+
- Ordinary CircuitJSON model and native extension nodes retain their identities
|
|
14
|
+
and are deeply frozen in place. Binary properties continue through the
|
|
15
|
+
shared defensive binary boundary.
|
|
16
|
+
- Raw input remains untrusted and is decoded and validated normally. The owned
|
|
17
|
+
path is applied only after the format parser has produced a new standard
|
|
18
|
+
local graph.
|
|
19
|
+
|
|
20
|
+
## API and compatibility
|
|
21
|
+
|
|
22
|
+
- Parser input, options, progress, cancellation, worker, and project contracts
|
|
23
|
+
are unchanged.
|
|
24
|
+
- Successful parsing returns the same `ecad-toolkit.document.v1` envelope with
|
|
25
|
+
the same `model`, `source`, `extensions`, `assets`, `diagnostics`, and
|
|
26
|
+
`statistics` fields.
|
|
27
|
+
- Canonical, metadata, full native-model, and empty extension selections retain
|
|
28
|
+
their existing behavior and return shapes.
|
|
29
|
+
- No class, method, parameter, package subpath, native extension, or renderer
|
|
30
|
+
behavior is removed or renamed.
|
|
31
|
+
|
|
32
|
+
## Performance verification
|
|
33
|
+
|
|
34
|
+
The shared ownership regressions verify retained identity, deep freeze,
|
|
35
|
+
bounded extension sealing, mutation isolation, binary protection, and direct
|
|
36
|
+
and worker result parity. The Altium parser and packed cross-toolkit suites
|
|
37
|
+
continue to validate the public envelope and renderer contracts.
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
3
|
+
"version": "1.4.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.
|
|
70
|
+
"circuitjson-toolkit": "^1.4.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 =
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
|
@@ -54,7 +57,7 @@ export class AltiumDocumentBuilder {
|
|
|
54
57
|
normalized.options.retainSource === 'reference'
|
|
55
58
|
? { sourceReference: normalized.sourceReference }
|
|
56
59
|
: {}
|
|
57
|
-
return DocumentResult.
|
|
60
|
+
return DocumentResult.createValidatedOwned(
|
|
58
61
|
{
|
|
59
62
|
model,
|
|
60
63
|
source: {
|
|
@@ -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(
|
|
24
|
+
SchematicSvgRenderer.#visibilityAwareDocument(normalized),
|
|
22
25
|
options
|
|
23
26
|
)
|
|
24
27
|
}
|