altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +7 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -3,7 +3,9 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicNoErcSymbolResolver } from './SchematicNoErcSymbolResolver.mjs'
6
7
  import { SchematicPinDesignatorInferer } from './SchematicPinDesignatorInferer.mjs'
8
+ import { SchematicTextRunParser } from './SchematicTextRunParser.mjs'
7
9
 
8
10
  /**
9
11
  * Helpers for normalized schematic pins, ports, and crosses.
@@ -66,11 +68,9 @@ export class SchematicPinParser {
66
68
  ),
67
69
  designator: ParserUtils.getField(record.fields, 'Designator'),
68
70
  orientation,
69
- electrical:
70
- ParserUtils.parseNumericField(
71
- record.fields,
72
- 'Electrical'
73
- ) || undefined,
71
+ electrical: SchematicPinParser.#parseSchematicPinElectrical(
72
+ record.fields
73
+ ),
74
74
  symbolOuter:
75
75
  ParserUtils.parseNumericField(
76
76
  record.fields,
@@ -94,6 +94,30 @@ export class SchematicPinParser {
94
94
  )
95
95
  }
96
96
 
97
+ /**
98
+ * Parses Altium's pin electrical type, including its omitted-field default.
99
+ * Formal schematic pin records omit Electrical for input pins and serialize
100
+ * passive pins explicitly as Electrical=4.
101
+ * @param {Record<string, string | string[]>} fields Pin record fields.
102
+ * @returns {number | undefined}
103
+ */
104
+ static #parseSchematicPinElectrical(fields) {
105
+ const explicitElectrical = ParserUtils.parseNumericField(
106
+ fields,
107
+ 'Electrical'
108
+ )
109
+
110
+ if (explicitElectrical !== null) {
111
+ return explicitElectrical
112
+ }
113
+
114
+ if (ParserUtils.parseNumericField(fields, 'FormalType') !== null) {
115
+ return 0
116
+ }
117
+
118
+ return undefined
119
+ }
120
+
97
121
  /**
98
122
  * Normalizes schematic port records into drawable port boxes.
99
123
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -386,24 +410,36 @@ export class SchematicPinParser {
386
410
  /**
387
411
  * Normalizes no-connect crosses from schematic records.
388
412
  * @param {{ fields: Record<string, string | string[]> }[]} records
389
- * @returns {{ x: number, y: number, size: number, color: string }[]}
413
+ * @returns {{ x: number, y: number, size: number, color: string, symbol: number | null, symbolName: string }[]}
390
414
  */
391
415
  static parseSchematicCrosses(records) {
392
416
  return records
393
- .map((record) => ({
394
- x:
395
- ParserUtils.parseNumericField(
396
- record.fields,
397
- 'Location.X'
398
- ) || 0,
399
- y:
400
- ParserUtils.parseNumericField(
401
- record.fields,
402
- 'Location.Y'
403
- ) || 0,
404
- size: 6,
405
- color: ParserUtils.toColor(record.fields.Color, '#ff0000')
406
- }))
417
+ .map((record) => {
418
+ const rawSymbol = ParserUtils.getField(record.fields, 'Symbol')
419
+ const symbol = ParserUtils.parseNumericField(
420
+ record.fields,
421
+ 'Symbol'
422
+ )
423
+
424
+ return {
425
+ x:
426
+ ParserUtils.parseNumericField(
427
+ record.fields,
428
+ 'Location.X'
429
+ ) || 0,
430
+ y:
431
+ ParserUtils.parseNumericField(
432
+ record.fields,
433
+ 'Location.Y'
434
+ ) || 0,
435
+ size: 6,
436
+ color: ParserUtils.toColor(record.fields.Color, '#ff0000'),
437
+ symbol,
438
+ symbolName: SchematicNoErcSymbolResolver.resolveSymbolName(
439
+ rawSymbol || symbol
440
+ )
441
+ }
442
+ })
407
443
  .filter((cross) => cross.x || cross.y)
408
444
  }
409
445
 
@@ -445,7 +481,7 @@ export class SchematicPinParser {
445
481
  options.recordType
446
482
  )
447
483
  ),
448
- width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
484
+ width: ParserUtils.parseSchematicLineWidth(fields),
449
485
  lineStyle,
450
486
  isBus: options.isBus === true ? true : undefined,
451
487
  recordType: options.recordType || undefined,
@@ -489,7 +525,7 @@ export class SchematicPinParser {
489
525
  fields.Color,
490
526
  SchematicPinParser.#resolveDefaultPolylineColor(fields, '7')
491
527
  ),
492
- width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
528
+ width: ParserUtils.parseSchematicLineWidth(fields),
493
529
  lineStyle
494
530
  })
495
531
  }
@@ -506,7 +542,7 @@ export class SchematicPinParser {
506
542
  fields.Color,
507
543
  SchematicPinParser.#resolveDefaultPolylineColor(fields, '7')
508
544
  ),
509
- width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
545
+ width: ParserUtils.parseSchematicLineWidth(fields),
510
546
  lineStyle
511
547
  })
512
548
 
@@ -609,8 +645,12 @@ export class SchematicPinParser {
609
645
  case 2:
610
646
  return 'filled-arrow'
611
647
  case 3:
612
- return 'circle'
648
+ return 'tail'
613
649
  case 4:
650
+ return 'filled-tail'
651
+ case 5:
652
+ return 'circle'
653
+ case 6:
614
654
  return 'square'
615
655
  default:
616
656
  return 'marker-' + shape
@@ -816,6 +856,13 @@ export class SchematicPinParser {
816
856
  SchematicPinParser.#isTwoPinNumericEndpointGroup(normalizedPins)
817
857
  ) {
818
858
  labelMode = 'number-only'
859
+ } else if (
860
+ SchematicPinParser.#isCompactSinglePinMarkerGroup(
861
+ normalizedPins,
862
+ names
863
+ )
864
+ ) {
865
+ labelMode = 'hidden'
819
866
  } else if (allPassive && normalizedPins.length <= 2) {
820
867
  labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
821
868
  normalizedPins
@@ -875,6 +922,31 @@ export class SchematicPinParser {
875
922
  }))
876
923
  }
877
924
 
925
+ /**
926
+ * Returns true when a single owner pin belongs to compact marker artwork
927
+ * rather than to a visibly numbered electrical contact.
928
+ * @param {{ designator: string, name: string, length: number, electrical?: number }[]} pins
929
+ * @param {string[]} names
930
+ * @returns {boolean}
931
+ */
932
+ static #isCompactSinglePinMarkerGroup(pins, names) {
933
+ if (pins.length !== 1 || names.length !== 0) {
934
+ return false
935
+ }
936
+
937
+ const pin = pins[0]
938
+ const designator = String(pin.designator || '').trim()
939
+ const length = Math.abs(Number(pin.length || 0))
940
+ const electrical = Number(pin.electrical)
941
+
942
+ return (
943
+ /^\d+$/.test(designator) &&
944
+ length > 0 &&
945
+ length <= 15 &&
946
+ (!Number.isFinite(electrical) || electrical === 4)
947
+ )
948
+ }
949
+
878
950
  /**
879
951
  * Returns true when a compact owner-drawn FET body uses semantic terminal
880
952
  * names internally but still exposes external numeric contact labels.
@@ -1142,49 +1214,11 @@ export class SchematicPinParser {
1142
1214
  * @returns {{ name: string, nameSegments?: { text: string, overline: boolean }[] }}
1143
1215
  */
1144
1216
  static #parseSchematicPinName(name) {
1145
- const characters = []
1146
-
1147
- for (const character of String(name || '').trim()) {
1148
- if (character === '\\') {
1149
- const previousCharacter = characters.at(-1)
1150
- if (previousCharacter) {
1151
- previousCharacter.overline = true
1152
- }
1153
- continue
1154
- }
1155
-
1156
- characters.push({
1157
- text: character,
1158
- overline: false
1159
- })
1160
- }
1161
-
1162
- const normalizedName = characters
1163
- .map((character) => character.text)
1164
- .join('')
1165
- const nameSegments = []
1166
-
1167
- for (const character of characters) {
1168
- const previousSegment = nameSegments.at(-1)
1169
- if (
1170
- previousSegment &&
1171
- previousSegment.overline === character.overline
1172
- ) {
1173
- previousSegment.text += character.text
1174
- continue
1175
- }
1176
-
1177
- nameSegments.push({
1178
- text: character.text,
1179
- overline: character.overline
1180
- })
1181
- }
1217
+ const parsed = SchematicTextRunParser.parseOptionalOverlineRuns(name)
1182
1218
 
1183
1219
  return {
1184
- name: normalizedName,
1185
- nameSegments: nameSegments.some((segment) => segment.overline)
1186
- ? nameSegments
1187
- : undefined
1220
+ name: parsed.text,
1221
+ nameSegments: parsed.segments
1188
1222
  }
1189
1223
  }
1190
1224
 
@@ -9,6 +9,7 @@ const {
9
9
  parseBoolean,
10
10
  parseNumericField,
11
11
  parseNumericFieldWithFraction,
12
+ parseSchematicLineWidth,
12
13
  toColor
13
14
  } = ParserUtils
14
15
 
@@ -98,6 +99,18 @@ export class SchematicPrimitiveParser {
98
99
  )
99
100
  }
100
101
 
102
+ /**
103
+ * Returns true when one point-listed record carries only an axis-aligned
104
+ * rectangular point list without authored `Location` and `Corner` fields.
105
+ * @param {Record<string, string | string[]>} fields
106
+ * @returns {boolean}
107
+ */
108
+ static isPointListedRectangleRecord(fields) {
109
+ return Boolean(
110
+ SchematicPrimitiveParser.#pointListedRectangleBounds(fields)
111
+ )
112
+ }
113
+
101
114
  /**
102
115
  * Normalizes record-7 polygon primitives into fill-capable polygons.
103
116
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -122,8 +135,7 @@ export class SchematicPrimitiveParser {
122
135
  fill: toColor(record.fields.AreaColor, color),
123
136
  isSolid: parseBoolean(record.fields.IsSolid),
124
137
  transparent: parseBoolean(record.fields.Transparent),
125
- lineWidth:
126
- parseNumericField(record.fields, 'LineWidth') || 1,
138
+ lineWidth: parseSchematicLineWidth(record.fields),
127
139
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
128
140
  record.fields,
129
141
  index
@@ -157,7 +169,7 @@ export class SchematicPrimitiveParser {
157
169
  points,
158
170
  segments,
159
171
  color: toColor(record.fields.Color, '#000000'),
160
- width: parseNumericField(record.fields, 'LineWidth') || 1,
172
+ width: parseSchematicLineWidth(record.fields),
161
173
  lineStyle:
162
174
  parseNumericField(record.fields, 'LineStyle') || 0,
163
175
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
@@ -221,8 +233,7 @@ export class SchematicPrimitiveParser {
221
233
  fill: toColor(record.fields.AreaColor, '#ffffff'),
222
234
  isSolid: parseBoolean(record.fields.IsSolid),
223
235
  transparent: parseBoolean(record.fields.Transparent),
224
- lineWidth:
225
- parseNumericField(record.fields, 'LineWidth') || 1,
236
+ lineWidth: parseSchematicLineWidth(record.fields),
226
237
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
227
238
  record.fields,
228
239
  index
@@ -268,8 +279,7 @@ export class SchematicPrimitiveParser {
268
279
  fill: toColor(record.fields.AreaColor, '#ffffff'),
269
280
  isSolid: parseBoolean(record.fields.IsSolid),
270
281
  transparent: parseBoolean(record.fields.Transparent),
271
- lineWidth:
272
- parseNumericField(record.fields, 'LineWidth') || 1,
282
+ lineWidth: parseSchematicLineWidth(record.fields),
273
283
  lineStyle:
274
284
  parseNumericField(record.fields, 'LineStyle') || 0,
275
285
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
@@ -327,8 +337,7 @@ export class SchematicPrimitiveParser {
327
337
  1
328
338
  ),
329
339
  color: toColor(record.fields.Color, '#000000'),
330
- lineWidth:
331
- parseNumericField(record.fields, 'LineWidth') || 1,
340
+ lineWidth: parseSchematicLineWidth(record.fields),
332
341
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
333
342
  record.fields,
334
343
  index
@@ -352,16 +361,32 @@ export class SchematicPrimitiveParser {
352
361
  static parseSchematicRectangles(records) {
353
362
  return records
354
363
  .map((record, index) => {
355
- const x1 = parseNumericField(record.fields, 'Location.X')
356
- const y1 = parseNumericField(record.fields, 'Location.Y')
357
- const x2 = parseNumericField(record.fields, 'Corner.X')
358
- const y2 = parseNumericField(record.fields, 'Corner.Y')
364
+ const pointListedBounds =
365
+ SchematicPrimitiveParser.#pointListedRectangleBounds(
366
+ record.fields
367
+ )
368
+ const x1 =
369
+ parseNumericField(record.fields, 'Location.X') ??
370
+ pointListedBounds?.minX ??
371
+ null
372
+ const y1 =
373
+ parseNumericField(record.fields, 'Location.Y') ??
374
+ pointListedBounds?.minY ??
375
+ null
376
+ const x2 =
377
+ parseNumericField(record.fields, 'Corner.X') ??
378
+ pointListedBounds?.maxX ??
379
+ null
380
+ const y2 =
381
+ parseNumericField(record.fields, 'Corner.Y') ??
382
+ pointListedBounds?.maxY ??
383
+ null
359
384
  const isRectangleRecord =
360
385
  SchematicPrimitiveParser.isRectangleRecord(record.fields)
361
386
  const isListedRectangle =
362
387
  SchematicPrimitiveParser.isListedRectangleRecord(
363
388
  record.fields
364
- )
389
+ ) || Boolean(pointListedBounds)
365
390
  const usesFrameFallback =
366
391
  SchematicPrimitiveParser.#shouldUseFrameFallback(
367
392
  record.fields,
@@ -400,11 +425,16 @@ export class SchematicPrimitiveParser {
400
425
  transparent: usesFrameFallback
401
426
  ? false
402
427
  : parseBoolean(record.fields.Transparent),
403
- lineWidth:
404
- parseNumericField(record.fields, 'LineWidth') || 1,
405
- lineStyle: usesFrameFallback
406
- ? 1
407
- : parseNumericField(record.fields, 'LineStyle') || 0,
428
+ lineWidth: parseSchematicLineWidth(record.fields),
429
+ lineStyle:
430
+ usesFrameFallback ||
431
+ SchematicPrimitiveParser.#shouldDefaultBlanketDash(
432
+ record.fields,
433
+ pointListedBounds
434
+ )
435
+ ? 1
436
+ : parseNumericField(record.fields, 'LineStyle') ||
437
+ 0,
408
438
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
409
439
  record.fields,
410
440
  index
@@ -570,7 +600,7 @@ export class SchematicPrimitiveParser {
570
600
  startAngle: startAngle === null ? 0 : startAngle,
571
601
  endAngle: endAngle === null ? 360 : endAngle,
572
602
  color: toColor(record.fields.Color, '#a44a1b'),
573
- width: parseNumericField(record.fields, 'LineWidth') || 1,
603
+ width: parseSchematicLineWidth(record.fields),
574
604
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
575
605
  record.fields,
576
606
  index
@@ -627,8 +657,7 @@ export class SchematicPrimitiveParser {
627
657
  fill: toColor(record.fields.AreaColor, '#ffffff'),
628
658
  isSolid: parseBoolean(record.fields.IsSolid),
629
659
  transparent: parseBoolean(record.fields.Transparent),
630
- lineWidth:
631
- parseNumericField(record.fields, 'LineWidth') || 1,
660
+ lineWidth: parseSchematicLineWidth(record.fields),
632
661
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
633
662
  record.fields,
634
663
  index
@@ -989,6 +1018,80 @@ export class SchematicPrimitiveParser {
989
1018
  )
990
1019
  }
991
1020
 
1021
+ /**
1022
+ * Returns true when a point-listed blanket should use Altium's dashed frame
1023
+ * style even when the printable record omits `LineStyle`.
1024
+ * @param {Record<string, string | string[]>} fields Record fields.
1025
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} pointListedBounds Point-listed bounds.
1026
+ * @returns {boolean}
1027
+ */
1028
+ static #shouldDefaultBlanketDash(fields, pointListedBounds) {
1029
+ return (
1030
+ Boolean(pointListedBounds) &&
1031
+ getField(fields, 'RECORD') === '225' &&
1032
+ getField(fields, 'LineStyle') === ''
1033
+ )
1034
+ }
1035
+
1036
+ /**
1037
+ * Resolves rectangle bounds from a point-listed axis-aligned frame.
1038
+ * @param {Record<string, string | string[]>} fields Record fields.
1039
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
1040
+ */
1041
+ static #pointListedRectangleBounds(fields) {
1042
+ const points = SchematicPrimitiveParser.#normalizeClosedPointList(
1043
+ SchematicPrimitiveParser.#collectPolygonPoints(fields)
1044
+ )
1045
+
1046
+ if (points.length !== 4) {
1047
+ return null
1048
+ }
1049
+
1050
+ const xs = [...new Set(points.map((point) => point.x))]
1051
+ const ys = [...new Set(points.map((point) => point.y))]
1052
+
1053
+ if (xs.length !== 2 || ys.length !== 2) {
1054
+ return null
1055
+ }
1056
+
1057
+ const minX = Math.min(...xs)
1058
+ const maxX = Math.max(...xs)
1059
+ const minY = Math.min(...ys)
1060
+ const maxY = Math.max(...ys)
1061
+ const corners = new Set([
1062
+ minX + ':' + minY,
1063
+ minX + ':' + maxY,
1064
+ maxX + ':' + minY,
1065
+ maxX + ':' + maxY
1066
+ ])
1067
+
1068
+ if (!points.every((point) => corners.has(point.x + ':' + point.y))) {
1069
+ return null
1070
+ }
1071
+
1072
+ return { minX, minY, maxX, maxY }
1073
+ }
1074
+
1075
+ /**
1076
+ * Removes an optional repeated closing point from one source point list.
1077
+ * @param {{ x: number, y: number }[]} points Source points.
1078
+ * @returns {{ x: number, y: number }[]}
1079
+ */
1080
+ static #normalizeClosedPointList(points) {
1081
+ if (points.length < 2) {
1082
+ return points
1083
+ }
1084
+
1085
+ const first = points[0]
1086
+ const last = points.at(-1)
1087
+
1088
+ if (first.x === last.x && first.y === last.y) {
1089
+ return points.slice(0, -1)
1090
+ }
1091
+
1092
+ return points
1093
+ }
1094
+
992
1095
  /**
993
1096
  * Collects one record-7 polygon point list in source order.
994
1097
  * @param {Record<string, string | string[]>} fields
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicFieldCoverageReportBuilder } from './SchematicFieldCoverageReportBuilder.mjs'
6
7
 
7
8
  const { parseBoolean, parseNumericField, toColor } = ParserUtils
8
9
 
@@ -55,6 +56,7 @@ export class SchematicQaReportBuilder {
55
56
  geometryFallbacks: SchematicQaReportBuilder.#geometryFallbacks(
56
57
  input?.texts
57
58
  ),
59
+ fieldCoverage: SchematicFieldCoverageReportBuilder.build(records),
58
60
  findings
59
61
  }
60
62
  }
@@ -0,0 +1,183 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
+
7
+ /**
8
+ * Parses schematic stream records from native framed streams or printable runs.
9
+ */
10
+ export class SchematicRecordStreamParser {
11
+ /**
12
+ * Parses one schematic stream into record objects.
13
+ * @param {ArrayBuffer} arrayBuffer Stream bytes.
14
+ * @returns {{ raw: string, fields: Record<string, string | string[]> }[]}
15
+ */
16
+ static parse(arrayBuffer) {
17
+ return SchematicRecordStreamParser.parseWithOpaqueRecords(arrayBuffer)
18
+ .records
19
+ }
20
+
21
+ /**
22
+ * Parses one schematic stream and preserves unmodelled native frames.
23
+ * @param {ArrayBuffer} arrayBuffer Stream bytes.
24
+ * @param {{ source?: string, sourceStorage?: string, sourceStream?: string }} [options]
25
+ * @returns {{ records: { raw: string, fields: Record<string, string | string[]> }[], opaqueRecords: object[] }}
26
+ */
27
+ static parseWithOpaqueRecords(arrayBuffer, options = {}) {
28
+ const framedRecords = SchematicRecordStreamParser.#parseFramedRecords(
29
+ arrayBuffer,
30
+ options
31
+ )
32
+
33
+ if (framedRecords) {
34
+ return framedRecords
35
+ }
36
+
37
+ return {
38
+ records: AsciiRecordParser.parse(arrayBuffer),
39
+ opaqueRecords: []
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Parses length-prefixed native records when the full stream validates as
45
+ * framed schematic data.
46
+ * @param {ArrayBuffer} arrayBuffer Stream bytes.
47
+ * @param {{ source?: string, sourceStorage?: string, sourceStream?: string }} options Parser metadata.
48
+ * @returns {{ records: { raw: string, fields: Record<string, string | string[]> }[], opaqueRecords: object[] } | null}
49
+ */
50
+ static #parseFramedRecords(arrayBuffer, options) {
51
+ const bytes = new Uint8Array(arrayBuffer)
52
+ const records = []
53
+ const opaqueRecords = []
54
+ let offset = 0
55
+ let recordIndex = 0
56
+
57
+ while (offset < bytes.byteLength) {
58
+ if (offset + 4 > bytes.byteLength) {
59
+ return null
60
+ }
61
+
62
+ const payloadLength = bytes[offset] | (bytes[offset + 1] << 8)
63
+ const separatorByte = bytes[offset + 2]
64
+ const recordType = bytes[offset + 3]
65
+ const payloadStart = offset + 4
66
+ const payloadEnd = payloadStart + payloadLength
67
+
68
+ if (
69
+ payloadLength <= 0 ||
70
+ separatorByte !== 0 ||
71
+ payloadEnd > bytes.byteLength
72
+ ) {
73
+ return null
74
+ }
75
+
76
+ if (recordType === 0) {
77
+ if (bytes[payloadEnd - 1] !== 0) {
78
+ return null
79
+ }
80
+
81
+ const raw = SchematicRecordStreamParser.#bytesToBinaryString(
82
+ bytes.subarray(payloadStart, payloadEnd - 1)
83
+ )
84
+
85
+ if (raw.trim()) {
86
+ records.push(AsciiRecordParser.parseRecord(raw))
87
+ }
88
+ } else {
89
+ opaqueRecords.push(
90
+ SchematicRecordStreamParser.#opaqueRecord(
91
+ bytes.subarray(payloadStart, payloadEnd),
92
+ recordType,
93
+ recordIndex,
94
+ options
95
+ )
96
+ )
97
+ }
98
+
99
+ offset = payloadEnd
100
+ recordIndex += 1
101
+ }
102
+
103
+ return records.length || opaqueRecords.length
104
+ ? { records, opaqueRecords }
105
+ : null
106
+ }
107
+
108
+ /**
109
+ * Builds one opaque framed-record metadata row.
110
+ * @param {Uint8Array} bytes Raw payload bytes.
111
+ * @param {number} frameType Native frame type byte.
112
+ * @param {number} recordIndex Zero-based frame index.
113
+ * @param {{ source?: string, sourceStorage?: string, sourceStream?: string }} options Parser metadata.
114
+ * @returns {object}
115
+ */
116
+ static #opaqueRecord(bytes, frameType, recordIndex, options) {
117
+ return SchematicRecordStreamParser.#stripUndefined({
118
+ source: options.source || 'schdoc',
119
+ sourceStorage: options.sourceStorage,
120
+ sourceStream: options.sourceStream,
121
+ frameType,
122
+ recordIndex,
123
+ byteLength: bytes.byteLength,
124
+ rawBase64: SchematicRecordStreamParser.#bytesToBase64(bytes)
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Converts bytes into a byte-preserving string.
130
+ * @param {Uint8Array} bytes Source bytes.
131
+ * @returns {string}
132
+ */
133
+ static #bytesToBinaryString(bytes) {
134
+ let value = ''
135
+
136
+ for (const byte of bytes) {
137
+ value += String.fromCharCode(byte)
138
+ }
139
+
140
+ return value
141
+ }
142
+
143
+ /**
144
+ * Encodes bytes as base64 without depending on Node globals.
145
+ * @param {Uint8Array} bytes Source bytes.
146
+ * @returns {string}
147
+ */
148
+ static #bytesToBase64(bytes) {
149
+ const alphabet =
150
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
151
+ let output = ''
152
+
153
+ for (let index = 0; index < bytes.byteLength; index += 3) {
154
+ const first = bytes[index]
155
+ const second = bytes[index + 1]
156
+ const third = bytes[index + 2]
157
+ const hasSecond = index + 1 < bytes.byteLength
158
+ const hasThird = index + 2 < bytes.byteLength
159
+ const value =
160
+ (first << 16) |
161
+ ((hasSecond ? second : 0) << 8) |
162
+ (hasThird ? third : 0)
163
+
164
+ output += alphabet[(value >> 18) & 0x3f]
165
+ output += alphabet[(value >> 12) & 0x3f]
166
+ output += hasSecond ? alphabet[(value >> 6) & 0x3f] : '='
167
+ output += hasThird ? alphabet[value & 0x3f] : '='
168
+ }
169
+
170
+ return output
171
+ }
172
+
173
+ /**
174
+ * Removes undefined values from an object.
175
+ * @param {object} row Row to strip.
176
+ * @returns {object}
177
+ */
178
+ static #stripUndefined(row) {
179
+ return Object.fromEntries(
180
+ Object.entries(row).filter(([, value]) => value !== undefined)
181
+ )
182
+ }
183
+ }