altium-toolkit 1.1.22 → 1.1.24

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 (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -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 +511 -1
  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 +2 -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/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -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
+ }
@@ -47,13 +47,18 @@ export class SchematicRecordTypeRegistry {
47
47
  [47, ['map-definer', 'implementation', true]],
48
48
  [48, ['implementation-parameters', 'implementation', true]],
49
49
  [209, ['note', 'annotation', true]],
50
+ [210, ['probe-marker', 'code-symbol', true]],
50
51
  [211, ['compile-mask', 'directive', true]],
51
52
  [215, ['harness-connector', 'harness', true]],
52
53
  [216, ['harness-entry', 'harness', true]],
53
54
  [217, ['harness-type', 'harness', true]],
54
55
  [218, ['signal-harness', 'harness', true]],
56
+ [220, ['code-symbol', 'code-symbol', true]],
57
+ [221, ['code-symbol-entry', 'code-symbol', true]],
58
+ [222, ['code-symbol-title', 'code-symbol', true]],
59
+ [223, ['code-symbol-source', 'code-symbol', true]],
55
60
  [225, ['blanket', 'directive', true]],
56
- [226, ['hyperlink', 'annotation', false]]
61
+ [226, ['hyperlink', 'annotation', true]]
57
62
  ])
58
63
 
59
64
  /**
@@ -13,7 +13,7 @@ export class SchematicSheetParser {
13
13
  /**
14
14
  * Parses sheet symbols and their child entries from schematic records.
15
15
  * @param {{ fields: Record<string, string | string[]>, recordIndex: number }[]} records
16
- * @returns {{ sheetSymbols: { x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, ownerIndex?: string, uniqueId: string, renderOrder: number, sourceRecordIndex: number, indexInSheet: number | null }[], sheetEntries: { ownerIndex: string, name: string, side: 'left' | 'right' | 'top' | 'bottom', direction: 'unspecified' | 'output' | 'input' | 'bidirectional', style: number, x: number, y: number, color: string, fill: string, textColor: string, harnessType: string, renderOrder: number }[] }}
16
+ * @returns {{ sheetSymbols: { x: number, y: number, width: number, height: number, name?: string, fileName?: string, color: string, fill: string, isSolid: boolean, transparent: boolean, ownerIndex?: string, uniqueId: string, renderOrder: number, sourceRecordIndex: number, indexInSheet: number | null }[], sheetEntries: { ownerIndex: string, name: string, side: 'left' | 'right' | 'top' | 'bottom', direction: 'unspecified' | 'output' | 'input' | 'bidirectional', style: number, x: number, y: number, color: string, fill: string, textColor: string, harnessType: string, renderOrder: number }[] }}
17
17
  */
18
18
  static parse(records) {
19
19
  const sheetSymbols = records
@@ -38,7 +38,7 @@ export class SchematicSheetParser {
38
38
  /**
39
39
  * Normalizes one `RECORD=15` sheet symbol.
40
40
  * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record
41
- * @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, ownerIndex?: string, uniqueId: string, renderOrder: number, sourceRecordIndex: number, indexInSheet: number | null } | null}
41
+ * @returns {{ x: number, y: number, width: number, height: number, name?: string, fileName?: string, color: string, fill: string, isSolid: boolean, transparent: boolean, ownerIndex?: string, uniqueId: string, renderOrder: number, sourceRecordIndex: number, indexInSheet: number | null } | null}
42
42
  */
43
43
  static #parseSheetSymbolRecord(record) {
44
44
  if (getField(record.fields, 'RECORD') !== '15') {
@@ -61,6 +61,12 @@ export class SchematicSheetParser {
61
61
  y,
62
62
  width,
63
63
  height,
64
+ ...(getField(record.fields, 'Name')
65
+ ? { name: getField(record.fields, 'Name') }
66
+ : {}),
67
+ ...(getField(record.fields, 'FileName')
68
+ ? { fileName: getField(record.fields, 'FileName') }
69
+ : {}),
64
70
  color: toColor(record.fields.Color, '#a44a1b'),
65
71
  fill: toColor(record.fields.AreaColor, '#ffe16f'),
66
72
  isSolid: parseBoolean(record.fields.IsSolid),
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
5
  import { EmbeddedFileInventoryBuilder } from './EmbeddedFileInventoryBuilder.mjs'
6
+ import { NativeStreamInventoryBuilder } from './NativeStreamInventoryBuilder.mjs'
7
+ import { SchematicRecordStreamParser } from './SchematicRecordStreamParser.mjs'
7
8
  import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
8
9
  import { OleConstants } from '../ole/OleConstants.mjs'
9
10
 
@@ -58,10 +59,10 @@ export class SchematicStreamExtractor {
58
59
  return null
59
60
  }
60
61
 
61
- let fileHeaderRecords
62
+ let fileHeaderParse
62
63
 
63
64
  try {
64
- fileHeaderRecords = SchematicStreamExtractor.#parseStreamRecords(
65
+ fileHeaderParse = SchematicStreamExtractor.#parseStreamRecords(
65
66
  compoundDocument,
66
67
  'FileHeader'
67
68
  )
@@ -73,32 +74,64 @@ export class SchematicStreamExtractor {
73
74
  const auxiliaryStreamNames = streamNames.filter((streamName) =>
74
75
  SchematicStreamExtractor.#isAuxiliaryPrintableStream(streamName)
75
76
  )
76
- const auxiliaryRecords = auxiliaryStreamNames.flatMap((streamName) =>
77
+ const auxiliaryParses = auxiliaryStreamNames.map((streamName) =>
77
78
  SchematicStreamExtractor.#parseStreamRecords(
78
79
  compoundDocument,
79
80
  streamName
80
81
  )
81
82
  )
82
- const records = [...fileHeaderRecords, ...auxiliaryRecords]
83
-
84
- if (!records.length) {
83
+ const records = [
84
+ ...fileHeaderParse.records,
85
+ ...auxiliaryParses.flatMap((entry) => entry.records)
86
+ ]
87
+ const opaqueRecords = [
88
+ ...fileHeaderParse.opaqueRecords,
89
+ ...auxiliaryParses.flatMap((entry) => entry.opaqueRecords)
90
+ ]
91
+
92
+ if (!records.length && !opaqueRecords.length) {
85
93
  return null
86
94
  }
87
95
 
96
+ const embeddedFiles = EmbeddedFileInventoryBuilder.buildFromStreams(
97
+ new Map(
98
+ streamNames.map((name) => [
99
+ name,
100
+ compoundDocument.getStream(name)
101
+ ])
102
+ ),
103
+ {
104
+ skipStreamNames: ['FileHeader', ...auxiliaryStreamNames]
105
+ }
106
+ )
107
+ const consumedStreamNames = [
108
+ 'FileHeader',
109
+ ...auxiliaryStreamNames,
110
+ ...embeddedFiles.files.map((file) => file.sourceStream),
111
+ ...embeddedFiles.diagnostics.map(
112
+ (diagnostic) => diagnostic.sourceStream
113
+ )
114
+ ]
115
+ const nativeStreams = NativeStreamInventoryBuilder.buildFromStreams(
116
+ new Map(
117
+ streamNames.map((name) => [
118
+ name,
119
+ compoundDocument.getStream(name)
120
+ ])
121
+ ),
122
+ {
123
+ source: 'schdoc',
124
+ consumedStreamNames,
125
+ knownStreamNames: ['FileHeader', ...auxiliaryStreamNames]
126
+ }
127
+ )
128
+
88
129
  return {
89
130
  records,
90
131
  streamNames,
91
- embeddedFiles: EmbeddedFileInventoryBuilder.buildFromStreams(
92
- new Map(
93
- streamNames.map((name) => [
94
- name,
95
- compoundDocument.getStream(name)
96
- ])
97
- ),
98
- {
99
- skipStreamNames: ['FileHeader', ...auxiliaryStreamNames]
100
- }
101
- )
132
+ opaqueRecords,
133
+ nativeStreams,
134
+ embeddedFiles
102
135
  }
103
136
  }
104
137
 
@@ -106,17 +139,23 @@ export class SchematicStreamExtractor {
106
139
  * Parses printable records from one compound-document stream.
107
140
  * @param {OleCompoundDocument} compoundDocument
108
141
  * @param {string} streamName
109
- * @returns {Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>}
142
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, opaqueRecords: object[] }}
110
143
  */
111
144
  static #parseStreamRecords(compoundDocument, streamName) {
112
- return AsciiRecordParser.parse(
145
+ const parsed = SchematicRecordStreamParser.parseWithOpaqueRecords(
113
146
  SchematicStreamExtractor.#toArrayBuffer(
114
147
  compoundDocument.getStream(streamName)
115
- )
116
- ).map((record) => ({
117
- ...record,
118
- sourceStream: streamName
119
- }))
148
+ ),
149
+ { sourceStream: streamName }
150
+ )
151
+
152
+ return {
153
+ records: parsed.records.map((record) => ({
154
+ ...record,
155
+ sourceStream: streamName
156
+ })),
157
+ opaqueRecords: parsed.opaqueRecords
158
+ }
120
159
  }
121
160
 
122
161
  /**
@@ -0,0 +1,76 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ /**
8
+ * Resolves native schematic text orientation flags.
9
+ */
10
+ export class SchematicTextOrientationResolver {
11
+ /**
12
+ * Resolves component designator/parameter orientation bit 1 to right
13
+ * anchoring.
14
+ * @param {Record<string, string | string[]>} fields Text record fields.
15
+ * @param {string} recordType Native text record type.
16
+ * @returns {'end' | null}
17
+ */
18
+ static resolveHorizontalAnchor(fields, recordType) {
19
+ if (
20
+ !SchematicTextOrientationResolver.#isComponentTextRecord(recordType)
21
+ ) {
22
+ return null
23
+ }
24
+
25
+ const orientation = ParserUtils.parseNumericField(fields, 'Orientation')
26
+ if (!Number.isInteger(orientation)) return null
27
+
28
+ return (orientation & 2) === 2 ? 'end' : null
29
+ }
30
+
31
+ /**
32
+ * Resolves component text orientation bit 1 to top source anchoring.
33
+ * @param {Record<string, string | string[]>} fields Text record fields.
34
+ * @param {string} recordType Native text record type.
35
+ * @returns {'top' | null}
36
+ */
37
+ static resolveVerticalAnchor(fields, recordType) {
38
+ return SchematicTextOrientationResolver.resolveHorizontalAnchor(
39
+ fields,
40
+ recordType
41
+ )
42
+ ? 'top'
43
+ : null
44
+ }
45
+
46
+ /**
47
+ * Returns true when a text record's orientation encodes a quarter-turn.
48
+ * Component designators and parameters use bit 0; older free labels use
49
+ * the literal orientation-1 value.
50
+ * @param {string} recordType Native text record type.
51
+ * @param {number | null} orientation Native orientation value.
52
+ * @returns {boolean}
53
+ */
54
+ static shouldRotateFromOrientation(recordType, orientation) {
55
+ if (!Number.isInteger(orientation)) {
56
+ return false
57
+ }
58
+
59
+ if (
60
+ SchematicTextOrientationResolver.#isComponentTextRecord(recordType)
61
+ ) {
62
+ return (orientation & 1) === 1
63
+ }
64
+
65
+ return orientation === 1
66
+ }
67
+
68
+ /**
69
+ * Returns true for component designator and parameter text records.
70
+ * @param {string} recordType Native text record type.
71
+ * @returns {boolean}
72
+ */
73
+ static #isComponentTextRecord(recordType) {
74
+ return recordType === '34' || recordType === '41'
75
+ }
76
+ }
@@ -3,6 +3,8 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicTextRunParser } from './SchematicTextRunParser.mjs'
7
+ import { SchematicTextOrientationResolver } from './SchematicTextOrientationResolver.mjs'
6
8
 
7
9
  /**
8
10
  * Helpers for normalized schematic text extraction.
@@ -134,7 +136,7 @@ export class SchematicTextParser {
134
136
  * @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
135
137
  * @param {Record<string, { size: number, family: string, bold: boolean, italic?: boolean, rotation: number }>} fonts
136
138
  * @param {Map<string, Record<string, string>>} [ownerMetadata]
137
- * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
139
+ * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', verticalAnchor?: 'top', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
138
140
  */
139
141
  static normalizeSchematicTextRecord(
140
142
  fields,
@@ -158,8 +160,9 @@ export class SchematicTextParser {
158
160
  ownerMetadata
159
161
  )
160
162
  )
163
+ const textRuns = SchematicTextRunParser.parseOptionalOverlineRuns(text)
161
164
 
162
- if (hidden || x === null || y === null || !text) {
165
+ if (hidden || x === null || y === null || !textRuns.text) {
163
166
  return null
164
167
  }
165
168
 
@@ -168,7 +171,7 @@ export class SchematicTextParser {
168
171
  fields,
169
172
  name,
170
173
  rawText,
171
- text,
174
+ textRuns.text,
172
175
  sheet
173
176
  )
174
177
  ) {
@@ -191,7 +194,8 @@ export class SchematicTextParser {
191
194
  const textRecord = {
192
195
  x,
193
196
  y,
194
- text,
197
+ text: textRuns.text,
198
+ textSegments: textRuns.segments,
195
199
  color: SchematicTextParser.#resolveSchematicTextColor(
196
200
  fields,
197
201
  recordType
@@ -211,6 +215,11 @@ export class SchematicTextParser {
211
215
  sourceOrientation:
212
216
  sourceOrientation === null ? undefined : sourceOrientation,
213
217
  isMirrored: isMirrored || undefined,
218
+ verticalAnchor:
219
+ SchematicTextOrientationResolver.resolveVerticalAnchor(
220
+ fields,
221
+ recordType
222
+ ) || undefined,
214
223
  powerPortDirection:
215
224
  SchematicTextParser.#resolvePowerPortDirection(
216
225
  fields,
@@ -597,8 +606,14 @@ export class SchematicTextParser {
597
606
  static #inferTextAnchor(fields, recordType, name, text, font, rotation) {
598
607
  const explicitAnchor =
599
608
  SchematicTextParser.#resolveSchematicTextJustificationAnchor(fields)
609
+ const orientationAnchor =
610
+ SchematicTextOrientationResolver.resolveHorizontalAnchor(
611
+ fields,
612
+ recordType
613
+ )
600
614
 
601
615
  if (recordType === '17') return 'middle'
616
+ if (orientationAnchor) return orientationAnchor
602
617
  if (explicitAnchor) return explicitAnchor
603
618
  if (
604
619
  SchematicTextParser.#shouldCenterSchematicNoteByDefault(
@@ -748,12 +763,9 @@ export class SchematicTextParser {
748
763
  */
749
764
  static #resolveTextRotation(fields, font, recordType) {
750
765
  if (recordType === '17') return 0
751
- if (recordType === '25') {
752
- const orientation = ParserUtils.parseNumericField(
753
- fields,
754
- 'Orientation'
755
- )
766
+ const orientation = ParserUtils.parseNumericField(fields, 'Orientation')
756
767
 
768
+ if (recordType === '25') {
757
769
  if (orientation === 1 || orientation === 3) {
758
770
  return 90
759
771
  }
@@ -765,7 +777,12 @@ export class SchematicTextParser {
765
777
  )
766
778
  if (explicitRotation !== null) return explicitRotation
767
779
  if (font.rotation) return font.rotation
768
- if (ParserUtils.parseNumericField(fields, 'Orientation') === 1) {
780
+ if (
781
+ SchematicTextOrientationResolver.shouldRotateFromOrientation(
782
+ recordType,
783
+ orientation
784
+ )
785
+ ) {
769
786
  return 90
770
787
  }
771
788
  return 0
@@ -870,8 +887,7 @@ export class SchematicTextParser {
870
887
  fields.Color || fields.TextColor,
871
888
  '#7b7753'
872
889
  ),
873
- lineWidth:
874
- ParserUtils.parseNumericField(fields, 'LineWidth') ?? undefined,
890
+ lineWidth: ParserUtils.parseSchematicLineWidth(fields),
875
891
  isSolid: ParserUtils.parseBoolean(fields.IsSolid),
876
892
  showBorder: ParserUtils.parseBoolean(fields.ShowBorder),
877
893
  textMargin:
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Parses schematic text runs that use backslash suffix markers for overlines.
7
+ */
8
+ export class SchematicTextRunParser {
9
+ /**
10
+ * Parses overline suffix markers into display text and contiguous runs.
11
+ * @param {string} value Source text.
12
+ * @returns {{ text: string, hasOverline: boolean, segments: { text: string, overline: boolean }[] }}
13
+ */
14
+ static parseOverlineRuns(value) {
15
+ const characters = []
16
+
17
+ for (const character of String(value || '').trim()) {
18
+ if (character === '\\') {
19
+ const previousCharacter = characters.at(-1)
20
+ if (previousCharacter) {
21
+ previousCharacter.overline = true
22
+ }
23
+ continue
24
+ }
25
+
26
+ characters.push({
27
+ text: character,
28
+ overline: false
29
+ })
30
+ }
31
+
32
+ const segments = SchematicTextRunParser.#segments(characters)
33
+
34
+ return {
35
+ text: characters.map((character) => character.text).join(''),
36
+ hasOverline: segments.some((segment) => segment.overline),
37
+ segments
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Returns only overline segments when a value contains overlined runs.
43
+ * @param {string} value Source text.
44
+ * @returns {{ text: string, segments?: { text: string, overline: boolean }[] }}
45
+ */
46
+ static parseOptionalOverlineRuns(value) {
47
+ const parsed = SchematicTextRunParser.parseOverlineRuns(value)
48
+
49
+ return {
50
+ text: parsed.text,
51
+ segments: parsed.hasOverline ? parsed.segments : undefined
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Merges character flags into contiguous text segments.
57
+ * @param {{ text: string, overline: boolean }[]} characters Character rows.
58
+ * @returns {{ text: string, overline: boolean }[]}
59
+ */
60
+ static #segments(characters) {
61
+ const segments = []
62
+
63
+ for (const character of characters) {
64
+ const previousSegment = segments.at(-1)
65
+ if (
66
+ previousSegment &&
67
+ previousSegment.overline === character.overline
68
+ ) {
69
+ previousSegment.text += character.text
70
+ continue
71
+ }
72
+
73
+ segments.push({
74
+ text: character.text,
75
+ overline: character.overline
76
+ })
77
+ }
78
+
79
+ return segments
80
+ }
81
+ }