master
barrett-land Publish 1.1.0 ed518ac 2mo ago
#!/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())