skill
kicad10-to-altium
Public Unreviewedby barrett-land
Import KiCad 10 symbols into Altium Designer correctly. Altium 26 supports KiCad through 9 only, so a raw KiCad 10 import silently loses property visibility and every hidden field lands visible. Downgrades v10 to v9 first, then imports headlessly, links the footprint, and repairs visibility.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
#!/usr/bin/env python3
"""Downgrade a KiCad 10 .kicad_sym symbol library to KiCad 9 format.
WHY THIS EXISTS
KiCad 9 cannot open KiCad 10 libraries at all ("Unable to load library"), and Altium's
importer understands KiCad through 9 but degrades on 10 -- most importantly it drops property
VISIBILITY, so fields hidden in KiCad arrive visible in Altium. For a third-party library that
ships v10 only, there is no "just re-save in KiCad 9" escape hatch. Hence this.
WHAT ACTUALLY CHANGES (measured by diffing the same symbol out of KiCad 8/9/10):
hide RELOCATED, not renamed. v9 nests (hide yes) INSIDE (effects ...);
v10 moved it OUT to be a direct child of (property ...). A regex that
rewrites the token in place produces a file that is NEITHER version --
KiCad 9 rejects it and Altium silently ignores the orphan token. This is
the whole reason a real parser is needed.
show_name v10 only -> dropped (cosmetic: show the field name beside its value)
do_not_autoplace v10 only -> dropped (cosmetic: pin a field against auto-placement)
in_pos_files v10 only -> dropped. NOT a real loss: v9 expresses this on the
FOOTPRINT as (attr exclude_from_pos_files), which is what pcbnew
actually reads when generating position files.
duplicate_pin_numbers_are_jumpers
v10 only -> dropped. Always "no" across all 3609 stdlib symbols,
which matches v9's implicit behaviour, so normally a no-op. If a
library sets it to "yes" that ERC semantic has no v9 equivalent --
the tool warns rather than pretending otherwise.
corner_radius v10 only -> dropped (rounded rectangles become square)
version 20251024 -> 20241209
SYMBOL ORDER Not a token change at all, and the one that actually bites. KiCad 9
resolves (extends "Base") in a SINGLE FORWARD PASS while parsing, so a
derived symbol placed before its base makes the entire library fail to
load. KiCad 10 resolves the reference regardless of order, so v10 files
are free to store the derived symbol first -- and real ones do. Convert
such a file token-by-token and every token is correct, yet KiCad 9 still
says only "Unable to load library", naming neither symbol. This tool
topologically reorders symbols so each base precedes its dependants.
Derived symbols are everywhere in practice (KiCad's own STM32 libraries
are built on them), so a converter that ignores order works on simple
passives and fails on almost every real IC.
Usage:
python3 kicad_v10_to_v9.py in.kicad_sym [-o out.kicad_sym] [--quiet]
Validate the result with KiCad 9 itself -- that is the only authority that matters:
kicad-cli sym export svg out.kicad_sym -o /tmp/svg
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
V10 = "20251024"
V9 = "20241209"
# Direct children of (property ...) that v9 has no grammar for.
PROPERTY_DROP = {"show_name", "do_not_autoplace"}
# Direct children of (symbol ...) that v9 has no grammar for.
SYMBOL_DROP = {"in_pos_files", "duplicate_pin_numbers_are_jumpers"}
# Anywhere.
GLOBAL_DROP = {"corner_radius"}
class Node(list):
"""An S-expression list. Atoms are plain str (quoted strings keep their quotes)."""
def parse(text: str) -> Node:
"""Parse S-expressions. Handles quoted strings with backslash escapes."""
i, n = 0, len(text)
stack: list[Node] = [Node()]
while i < n:
c = text[i]
if c.isspace():
i += 1
elif c == "(":
node = Node()
stack[-1].append(node)
stack.append(node)
i += 1
elif c == ")":
if len(stack) == 1:
raise ValueError(f"unbalanced ')' at offset {i}")
stack.pop()
i += 1
elif c == '"':
j = i + 1
while j < n:
if text[j] == "\\":
j += 2
continue
if text[j] == '"':
break
j += 1
stack[-1].append(text[i : j + 1])
i = j + 1
else:
j = i
while j < n and not text[j].isspace() and text[j] not in "()":
j += 1
stack[-1].append(text[i:j])
i = j
if len(stack) != 1:
raise ValueError("unbalanced '(' -- unexpected end of input")
return stack[0]
def head(node) -> str | None:
"""The tag of an S-expression, e.g. 'property' for (property "Foo" ...)."""
if isinstance(node, Node) and node and isinstance(node[0], str):
return node[0]
return None
def dump(node, indent: int = 0) -> str:
"""Serialise back to KiCad's tab-indented style."""
if not isinstance(node, Node):
return str(node)
tag = head(node)
# Keep short leaf forms on one line, which is what KiCad does.
if all(not isinstance(c, Node) for c in node):
return "(" + " ".join(str(c) for c in node) + ")"
pad = "\t" * indent
out = ["(" + (tag or "")]
inline = []
rest = []
for child in node[1:]:
(inline if not isinstance(child, Node) and not rest else rest).append(child)
if inline:
out[0] += " " + " ".join(str(c) for c in inline)
for child in rest:
out.append(pad + "\t" + dump(child, indent + 1))
return ("\n".join(out)) + "\n" + pad + ")"
class Stats:
def __init__(self):
self.hide_moved = 0
self.hide_dropped = 0
self.dropped: dict[str, int] = {}
self.jumpers_yes = 0
self.reordered = 0
def drop(self, tag: str):
self.dropped[tag] = self.dropped.get(tag, 0) + 1
def convert_property(prop: Node, st: Stats) -> Node:
"""Move (hide yes) into (effects ...) and drop v10-only children."""
hide_yes = False
kept: list = []
for child in prop:
tag = head(child)
if tag == "hide":
val = child[1] if len(child) > 1 else "yes"
if val == "yes":
hide_yes = True
st.hide_moved += 1
else:
st.hide_dropped += 1
continue
if tag in PROPERTY_DROP:
st.drop(tag)
continue
kept.append(child)
out = Node(kept)
if hide_yes:
effects = next((c for c in out if head(c) == "effects"), None)
if effects is None:
effects = Node(["effects"])
out.append(effects)
# v9 puts (hide yes) inside (effects ...), after (font ...).
if not any(head(c) == "hide" for c in effects):
effects.append(Node(["hide", "yes"]))
return out
def walk(node, st: Stats, parent_tag: str | None = None):
if not isinstance(node, Node):
return node
tag = head(node)
if tag == "property":
node = convert_property(node, st)
kept = Node()
for child in node:
ctag = head(child)
if ctag in GLOBAL_DROP:
st.drop(ctag)
continue
if tag == "symbol" and ctag in SYMBOL_DROP:
if ctag == "duplicate_pin_numbers_are_jumpers":
val = child[1] if len(child) > 1 else "no"
if val == "yes":
st.jumpers_yes += 1
st.drop(ctag)
continue
if ctag == "version" and len(child) > 1 and child[1] == V10:
child = Node(["version", V9])
kept.append(walk(child, st, tag) if isinstance(child, Node) else child)
return kept
def order_symbols(root: Node, st: Stats) -> Node:
"""Emit base symbols BEFORE the symbols that (extends ...) them.
KiCad 9 resolves `extends` in a single forward pass while parsing, so a derived
symbol that appears before its base makes the whole library fail to load --
`kicad-cli` reports only "Unable to load library", naming neither symbol. KiCad 10
resolves the reference independently of order, so a v10 library is free to store
the derived symbol first, and many do. Converting such a file token-by-token
therefore produces valid-looking v9 that KiCad 9 still refuses.
Stable: symbols that need no move keep their original relative order.
"""
lib = root[0] if len(root) == 1 and head(root[0]) == "kicad_symbol_lib" else None
if lib is None:
return root
syms = [c for c in lib[1:] if head(c) == "symbol"]
if len(syms) < 2:
return root
def name_of(s):
return s[1] if len(s) > 1 and isinstance(s[1], str) else None
def base_of(s):
for c in s:
if head(c) == "extends" and len(c) > 1:
return c[1]
return None
by_name = {name_of(s): s for s in syms}
ordered, placed = [], set()
def place(s, seen):
sid = id(s)
if sid in placed:
return
base = base_of(s)
parent = by_name.get(base)
# Missing base or a cycle: emit in place rather than guess.
if parent is not None and id(parent) not in placed and id(parent) not in seen:
place(parent, seen | {sid})
if sid not in placed:
placed.add(sid)
ordered.append(s)
for s in syms:
place(s, set())
if [id(s) for s in ordered] != [id(s) for s in syms]:
st.reordered = sum(1 for a, b in zip(ordered, syms) if id(a) != id(b))
out = Node([lib[0]])
it = iter(ordered)
for c in lib[1:]:
out.append(next(it) if head(c) == "symbol" else c)
return Node([out])
return root
def main() -> int:
ap = argparse.ArgumentParser(description="Downgrade a KiCad 10 .kicad_sym to KiCad 9.")
ap.add_argument("input")
ap.add_argument("-o", "--output")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
src = Path(args.input)
text = src.read_text(encoding="utf-8-sig") # tolerate a BOM; never write one back
if f"(version {V10})" not in text.replace("\n", " ").replace("\t", " "):
found = "unknown"
for line in text.splitlines()[:8]:
if "(version" in line:
found = line.strip()
break
print(f"warning: input does not declare version {V10} (saw: {found})", file=sys.stderr)
tree = parse(text)
st = Stats()
out = order_symbols(walk(tree, st), st)
body = "\n".join(dump(child) for child in out) + "\n"
dst = Path(args.output) if args.output else src.with_name(src.stem + "_v9.kicad_sym")
dst.write_text(body, encoding="utf-8")
if not args.quiet:
print(f"wrote {dst} ({dst.stat().st_size} bytes, from {src.stat().st_size})")
print(f" hide relocated into (effects ...): {st.hide_moved}")
if st.hide_dropped:
print(f" (hide no) dropped: {st.hide_dropped}")
for tag, count in sorted(st.dropped.items()):
print(f" dropped {tag}: {count}")
if st.reordered:
print(f" reordered {st.reordered} symbol(s) so each (extends ...) base "
"comes first -- KiCad 9 resolves extends in one forward pass")
if st.jumpers_yes:
print(f" !! {st.jumpers_yes} symbol(s) had duplicate_pin_numbers_are_jumpers=yes -- "
"that ERC semantic has NO v9 equivalent and is genuinely lost")
return 0
if __name__ == "__main__":
sys.exit(main())