-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsplit_kicad_sym.py
More file actions
310 lines (244 loc) · 9.43 KB
/
Copy pathsplit_kicad_sym.py
File metadata and controls
310 lines (244 loc) · 9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
#!/usr/bin/env python3
"""
Completely vibe-coded, use with caution!
Split KiCad V10 monolithic .kicad_sym library files into per-symbol files
stored inside a <LibName>.kicad_symdir/ directory.
KiCad V10 symdir layout
Sym/
Resistors.kicad_symdir/
R-POT_3214W-1-103E.kicad_sym
R_0402.kicad_sym
...
Each per-symbol file is a self-contained kicad_symbol_lib with exactly one
(symbol ...) entry, sharing the same header (version / generator).
Usage
python split_kicad_sym.py # process all *.kicad_sym in ./Sym
python split_kicad_sym.py Sym/mylib.kicad_sym # single file
python split_kicad_sym.py --dry-run # show what would be created
Dependencies
pip install sexpdata # S-expression parser used by KiCad tooling
"""
import argparse
import os
import re
import sys
from pathlib import Path
# Try to import sexpdata; give a clear install hint if missing.
try:
import sexpdata
from sexpdata import Symbol, dumps, loads
except ImportError:
sys.exit(
"ERROR: 'sexpdata' is not installed.\n"
"Install it with: pip install sexpdata"
)
# Helpers
def _sym_name(sym_node) -> str:
"""Return the symbol name string from a parsed (symbol \"NAME\" ...) node."""
# sym_node[0] == Symbol('symbol'), sym_node[1] == the name string
return sym_node[1]
def _is_symbol_node(node) -> bool:
"""True when *node* is a list whose first element is Symbol('symbol')."""
return (
isinstance(node, list)
and node
and isinstance(node[0], sexpdata.Symbol)
and node[0].value() == "symbol"
)
def _collect_header_tokens(parsed) -> list:
"""
Return all top-level tokens that are NOT (symbol ...) entries.
These form the library header: version, generator, generator_version.
"""
return [tok for tok in parsed[1:] if not _is_symbol_node(tok)]
def _collect_symbol_nodes(parsed) -> list:
"""Return all top-level (symbol ...) nodes from the parsed library."""
return [tok for tok in parsed[1:] if _is_symbol_node(tok)]
# sexpdata dumps produces compact output; we want KiCad's indented style.
# We use a simple recursive pretty-printer that matches KiCad's conventions.
def _escape(s: str) -> str:
"""Escape backslashes and double-quotes inside a string value."""
return s.replace("\\", "\\\\").replace('"', '\\"')
def _pretty(node, indent: int = 0) -> str:
"""Recursively pretty-print an S-expression node."""
tab = "\t" * indent
if isinstance(node, sexpdata.Symbol):
return node.value()
if isinstance(node, str):
return f'"{_escape(node)}"'
if isinstance(node, bool):
# sexpdata maps yes/no to bool in some versions - keep as symbol
return "yes" if node else "no"
if isinstance(node, (int, float)):
# Preserve integer vs float representation
if isinstance(node, float) and node == int(node) and "." not in str(node):
return str(int(node))
return str(node)
if not isinstance(node, list):
return str(node)
# It's a list / S-expression
if not node:
return "()"
head = node[0]
head_str = _pretty(head, 0)
# Leaf list: all children are atoms, creates single line
children = node[1:]
all_atoms = all(not isinstance(c, list) for c in children)
if all_atoms:
parts = " ".join(_pretty(c, 0) for c in children)
return f"{tab}({head_str} {parts})" if parts else f"{tab}({head_str})"
# Mixed / nested: head on same line, children indented
lines = [f"{tab}({head_str}"]
for child in children:
if isinstance(child, list):
lines.append(_pretty(child, indent + 1))
else:
lines.append(f"{tab}\t{_pretty(child, 0)}")
lines.append(f"{tab})")
return "\n".join(lines)
def build_single_symbol_lib(header_tokens: list, symbol_node: list,
version: str, generator: str,
generator_version: str) -> str:
"""
Produce the text content of a standalone single-symbol .kicad_sym file.
Rather than going through _pretty for the whole file (which can mis-handle
the complex geometry nodes), we splice the raw text of the symbol back in -
see extract_raw_symbol_text() below. This function is used only when
raw-text extraction is not possible.
"""
lines = [
f"(kicad_symbol_lib",
f"\t(version {version})",
f"\t(generator \"{generator}\")",
f"\t(generator_version \"{generator_version}\")",
]
lines.append(_pretty(symbol_node, indent=1))
lines.append(")")
return "\n".join(lines) + "\n"
# Raw-text symbol extractor
# (avoids floating-point round-trip issues with the pretty-printer)
def extract_raw_symbols(text: str) -> dict[str, str]:
"""
Extract the raw text block for every top-level (symbol ...) in *text*.
Returns {symbol_name: raw_block_string}.
The algorithm tracks parenthesis depth to find the exact byte range of
each top-level symbol block, so it is not confused by nested symbols
(sub-units like symbol_0_0, symbol_0_1, etc.).
"""
results: dict[str, str] = {}
# Match the start of a top-level symbol: exactly one tab then (symbol "..."
pattern = re.compile(r'^\t\(symbol\s+"([^"]+)"', re.MULTILINE)
for m in pattern.finditer(text):
name = m.group(1)
start = m.start()
depth = 0
i = start
while i < len(text):
ch = text[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
results[name] = text[start : i + 1]
break
i += 1
return results
def extract_header_info(text: str) -> tuple[str, str, str]:
"""
Parse version, generator, and generator_version from the raw library text.
Returns (version, generator, generator_version) as strings.
"""
ver = re.search(r'\(version\s+(\d+)\)', text)
gen = re.search(r'\(generator\s+"([^"]+)"\)', text)
gen_ver = re.search(r'\(generator_version\s+"([^"]+)"\)', text)
version = ver.group(1) if ver else "20251024"
generator = gen.group(1) if gen else "kicad_symbol_editor"
generator_ver = gen_ver.group(1) if gen_ver else "10.0"
return version, generator, generator_ver
# Core: split one library file
def split_library(src: Path, output_root: Path, dry_run: bool = False) -> int:
"""
Split *src* (.kicad_sym) into per-symbol files under
*output_root* / <src.stem>.kicad_symdir/.
Returns the number of symbols written (or that would be written).
"""
print(f"\n-> {src}")
raw_text = src.read_text(encoding="utf-8")
version, generator, generator_ver = extract_header_info(raw_text)
raw_symbols = extract_raw_symbols(raw_text)
if not raw_symbols:
print(" (no symbols found - skipped)")
return 0
symdir = output_root / f"{src.stem}.kicad_symdir"
if not dry_run:
symdir.mkdir(parents=True, exist_ok=True)
count = 0
for sym_name, sym_raw in sorted(raw_symbols.items()):
# Build the per-symbol file content
file_lines = [
"(kicad_symbol_lib",
f"\t(version {version})",
f'\t(generator "{generator}")',
f'\t(generator_version "{generator_ver}")',
sym_raw, # already indented with one leading tab
")",
]
file_content = "\n".join(file_lines) + "\n"
# Sanitise symbol name for use as a filename
safe_name = re.sub(r'[<>:"/\\|?*]', "_", sym_name)
dest = symdir / f"{safe_name}.kicad_sym"
if dry_run:
print(f"[DRY-RUN] would write -> {dest}")
else:
dest.write_text(file_content, encoding="utf-8")
print(f"{dest.name} OK")
count += 1
print(f"-> {count} symbol(s) {'would be ' if dry_run else ''}written to {symdir}")
return count
# CLI
def main() -> None:
parser = argparse.ArgumentParser(
description="Split KiCad V10 .kicad_sym libraries into per-symbol kicad_symdir directories.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"files",
nargs="*",
metavar="FILE.kicad_sym",
help="Library file(s) to split. Defaults to all *.kicad_sym in ./Sym/",
)
parser.add_argument(
"--outdir", "-o",
default=None,
metavar="DIR",
help=(
"Output root directory for the .kicad_symdir folders. "
"Defaults to the same directory as each source file."
),
)
parser.add_argument(
"--dry-run", "-n",
action="store_true",
help="Show what would be created without writing any files.",
)
args = parser.parse_args()
# Collect source files
if args.files:
sources = [Path(f) for f in args.files]
else:
sym_dir = Path(__file__).parent / "Sym"
sources = sorted(sym_dir.glob("*.kicad_sym"))
if not sources:
sys.exit("No .kicad_sym files found.")
total = 0
for src in sources:
if not src.is_file():
print(f"WARNING: {src} is not a file - skipped.", file=sys.stderr)
continue
out_root = Path(args.outdir) if args.outdir else src.parent
total += split_library(src, out_root, dry_run=args.dry_run)
print(f"\nDone - {total} symbol file(s) total.")
if __name__ == "__main__":
main()