main
TylerK Publish 1.0.0 9d36a4a 1mo ago
"""Read KiCad .kicad_sym libraries: extract symbol blocks, flatten `extends`, expose pin geometry."""
import re, os

class SymLib:
    def __init__(self, paths):
        self.raw = {}      # (lib, name) -> block text
        self.libs = {}
        for p in paths:
            lib = os.path.basename(p).replace('.kicad_sym', '')
            txt = open(p, errors='replace').read()
            self.libs[lib] = txt
            for m in re.finditer(r'^\t\(symbol "([^"]+)"\n(.*?)^\t\)$', txt, re.M | re.S):
                self.raw[(lib, m.group(1))] = m.group(0)

    def block(self, lib, name):
        return self.raw.get((lib, name))

    def _extends(self, blk):
        m = re.search(r'^\t\t\(extends "([^"]+)"\)', blk, re.M)
        return m.group(1) if m else None

    def resolved(self, lib, name):
        """Return (block_text_with_children, effective_child_basename)."""
        blk = self.block(lib, name)
        if blk is None:
            raise KeyError(f"{lib}:{name}")
        par = self._extends(blk)
        if not par:
            return blk, name
        pblk, pbase = self.resolved(lib, par)
        return pblk, pbase

    def pins(self, lib, name):
        """{unit: [(number, pinname, etype, x, y, angle)]} using the resolved (parent) geometry.

        Unit 0 in KiCad's convention is pins COMMON TO ALL UNITS (e.g. a timer's
        shared GND/VCC), not an independently-placeable unit on its own - it is
        merged into every other real unit here, matching how eeschema treats it.
        Placing "unit 0" as its own instance (as if it were a normal unit) produces
        a symbol KiCad does not recognize as electrically part of the same part.
        """
        blk, base = self.resolved(lib, name)
        out = {}
        for um in re.finditer(r'\t\t\(symbol "' + re.escape(base) + r'_(\d+)_(\d+)"\n(.*?)(?=\n\t\t\(symbol "|\Z)',
                              blk, re.S):
            unit, style, body = int(um.group(1)), int(um.group(2)), um.group(3)
            for pm in re.finditer(
                    r'\(pin (\w+) \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)\s*\n\s*\(length [\d.]+\)(.*?)\(number "([^"]+)"',
                    body, re.S):
                etype, x, y, ang, mid, num = pm.groups()
                nm = re.search(r'\(name "([^"]*)"', mid)
                out.setdefault(unit, [])
                if not any(p[0] == num for p in out[unit]):
                    out[unit].append((num, nm.group(1) if nm else '', etype,
                                      float(x), float(y), int(ang)))
        if 0 in out:
            common = out.pop(0)
            if out:
                for u in out:
                    for p in common:
                        if not any(q[0] == p[0] for q in out[u]):
                            out[u].append(p)
            else:
                # symbol has ONLY common pins (e.g. PWR_FLAG) - unit 0 IS the part
                out[1] = common
        return out

    @staticmethod
    def _forms(body):
        """Split a symbol body into its top-level (...) forms, in order."""
        out, i, n = [], 0, len(body)
        while i < n:
            if body[i] != '(':
                i += 1; continue
            d, j, ins, esc = 0, i, False, False
            while j < n:
                ch = body[j]
                if esc: esc = False
                elif ch == '\\': esc = True
                elif ch == '"': ins = not ins
                elif not ins:
                    if ch == '(': d += 1
                    elif ch == ')':
                        d -= 1
                        if d == 0: j += 1; break
                j += 1
            out.append(body[i:j]); i = j
        return out

    def lib_symbol_entry(self, lib, name):
        """The (symbol "lib:name" ...) text to embed in a schematic's lib_symbols.

        For a derived symbol (extends), compose the parent's geometry/settings with
        the CHILD's own properties, which is how eeschema resolves it.
        """
        blk = self.block(lib, name)
        body = blk.split('\n', 1)[1].rsplit('\t)', 1)[0]
        par = self._extends(blk)
        if not par:
            return f'\t(symbol "{lib}:{name}"\n{body}\t)'
        pblk, pbase = self.resolved(lib, par)
        pbody = pblk.split('\n', 1)[1].rsplit('\t)', 1)[0]
        kid = {}
        for f in self._forms(body):
            m = re.match(r'\(property "([^"]+)"', f)
            if m: kid[m.group(1)] = f
        head, units, tail = [], [], []
        for f in self._forms(pbody):
            k = re.match(r'\((\w+)', f).group(1)
            if k == 'symbol':
                units.append(f.replace(f'"{pbase}_', f'"{name}_'))
            elif k == 'property':
                nm = re.match(r'\(property "([^"]+)"', f).group(1)
                if nm in kid:
                    head.append(kid.pop(nm))
                else:
                    head.append(f)
            elif k == 'embedded_fonts':
                tail.append(f)
            else:
                head.append(f)
        head.extend(kid.values())
        parts = head + units + tail
        return f'\t(symbol "{lib}:{name}"\n' + '\n'.join('\t\t' + x.replace('\n\t\t', '\n\t\t') for x in parts) + '\n\t)'