278 lines
12 KiB
Python
278 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compile an Altium **integrated library** (.IntLib) from a .SchLib + .PcbLib, in pure Python.
|
|
|
|
An .IntLib is an OLE compound file with five streams:
|
|
SchLib/0.schlib 0x02 + zlib(<the .SchLib OLE bytes>)
|
|
PCBLib/0.pcblib 0x02 + zlib(<the .PcbLib OLE bytes>)
|
|
LibCrossRef.Txt symbol -> footprint cross-reference index (length-prefixed strings)
|
|
Parameters .bin the Components-panel search index (parameter string)
|
|
Version.Txt constant \x00 + uint32(2)
|
|
|
|
We don't hand-roll a compound-file writer: we take a known-good single-component .IntLib as a
|
|
**container template** and swap in this part's five stream contents, then rebuild the OLE with the
|
|
same directory tree (reusing schlib_write.read_container / rebuild, already proven to emit
|
|
Altium-valid OLEs). The symbol->footprint linkage lives inside the embedded .SchLib itself
|
|
(RECORD=45 model link), so the compiled library resolves the footprint with no external PcbLib.
|
|
"""
|
|
import argparse, json, os, re, struct, sys, zlib
|
|
import olefile
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
|
|
from schlib_write import read_container
|
|
|
|
FREESECT = 0xFFFFFFFF; ENDOFCHAIN = 0xFFFFFFFE; FATSECT = 0xFFFFFFFD
|
|
SEC = 512; MINI = 64; CUTOFF = 4096
|
|
|
|
|
|
def _pad(b, n):
|
|
return b + b"\x00" * ((-len(b)) % n)
|
|
|
|
|
|
def write_cfb(entries, content):
|
|
"""Serialise an OLE/CFB compound file from raw 128-byte directory entries + {sid: bytes},
|
|
reproducing Altium's on-disk SECTOR ORDER: [FAT][directory][miniFAT][mini-stream][big streams].
|
|
(schlib_write.rebuild is spec-valid but places the FAT last; Altium's IntLib reader is strict
|
|
about big streams and expects the conventional FAT-first layout, so we match it here.)
|
|
Streams < 4096 B live in the mini-stream; >= 4096 B get their own FAT sectors."""
|
|
minis = [sid for sid, c in content.items() if len(c) < CUTOFF]
|
|
bigs = [sid for sid, c in content.items() if len(c) >= CUTOFF]
|
|
|
|
# mini-stream + mini-FAT
|
|
ministream = b""; mini_start = {}
|
|
for sid in minis:
|
|
mini_start[sid] = len(ministream) // MINI
|
|
ministream += _pad(content[sid], MINI)
|
|
n_mini = len(ministream) // MINI
|
|
minifat = [ENDOFCHAIN] * n_mini
|
|
for sid in minis:
|
|
st = mini_start[sid]; cnt = (len(content[sid]) + MINI - 1) // MINI or 1
|
|
for k in range(cnt - 1):
|
|
minifat[st + k] = st + k + 1
|
|
minifat_bytes = _pad(b"".join(struct.pack("<I", x) for x in minifat), SEC) if n_mini else b""
|
|
n_mf = len(minifat_bytes) // SEC
|
|
|
|
ms_p = _pad(ministream, SEC); n_ms = len(ms_p) // SEC
|
|
dir_bytes = _pad(b"".join(bytes(e) for e in entries), SEC); n_dir = len(dir_bytes) // SEC
|
|
big_pad = {sid: _pad(content[sid], SEC) for sid in bigs}
|
|
big_k = {sid: len(big_pad[sid]) // SEC for sid in bigs}
|
|
|
|
non_fat = n_dir + n_mf + n_ms + sum(big_k.values())
|
|
n_fat = 1
|
|
while (n_fat + non_fat) * 4 > n_fat * SEC:
|
|
n_fat += 1
|
|
total = n_fat + non_fat
|
|
if n_fat > 109:
|
|
sys.exit("too many FAT sectors for a header-only DIFAT")
|
|
|
|
# sector assignment — FAT first, matching Altium
|
|
fat_secs = list(range(0, n_fat)); cur = n_fat
|
|
dir_secs = list(range(cur, cur + n_dir)); cur += n_dir
|
|
mf_secs = list(range(cur, cur + n_mf)); cur += n_mf
|
|
ms_secs = list(range(cur, cur + n_ms)); cur += n_ms
|
|
big_secs = {}
|
|
for sid in bigs:
|
|
big_secs[sid] = list(range(cur, cur + big_k[sid])); cur += big_k[sid]
|
|
|
|
# patch directory entries (start sector + 64-bit size)
|
|
def setentry(e, start, size):
|
|
e[116:120] = struct.pack("<I", start); e[120:128] = struct.pack("<Q", size)
|
|
for sid in bigs:
|
|
setentry(entries[sid], big_secs[sid][0], len(content[sid]))
|
|
for sid in minis:
|
|
setentry(entries[sid], mini_start[sid], len(content[sid]))
|
|
setentry(entries[0], ms_secs[0] if n_ms else ENDOFCHAIN, len(ministream))
|
|
dir_bytes = _pad(b"".join(bytes(e) for e in entries), SEC)
|
|
|
|
# FAT
|
|
FAT = [FREESECT] * total
|
|
def chain(secs):
|
|
for k in range(len(secs) - 1):
|
|
FAT[secs[k]] = secs[k + 1]
|
|
if secs:
|
|
FAT[secs[-1]] = ENDOFCHAIN
|
|
chain(dir_secs); chain(mf_secs); chain(ms_secs)
|
|
for sid in bigs:
|
|
chain(big_secs[sid])
|
|
for s in fat_secs:
|
|
FAT[s] = FATSECT
|
|
fat_bytes = _pad(b"".join(struct.pack("<I", x) for x in FAT), SEC)
|
|
|
|
# header
|
|
h = bytearray(512)
|
|
h[0:8] = bytes([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])
|
|
h[24:26] = struct.pack("<H", 0x003E); h[26:28] = struct.pack("<H", 3)
|
|
h[28:30] = struct.pack("<H", 0xFFFE); h[30:32] = struct.pack("<H", 9); h[32:34] = struct.pack("<H", 6)
|
|
h[44:48] = struct.pack("<I", n_fat)
|
|
h[48:52] = struct.pack("<I", dir_secs[0])
|
|
h[56:60] = struct.pack("<I", CUTOFF)
|
|
h[60:64] = struct.pack("<I", mf_secs[0] if n_mf else ENDOFCHAIN)
|
|
h[64:68] = struct.pack("<I", n_mf)
|
|
h[68:72] = struct.pack("<I", ENDOFCHAIN)
|
|
for i in range(109):
|
|
h[76 + 4 * i:80 + 4 * i] = struct.pack("<I", fat_secs[i] if i < n_fat else FREESECT)
|
|
|
|
out = bytearray(h) + fat_bytes + dir_bytes + minifat_bytes + ms_p
|
|
for sid in bigs:
|
|
out += big_pad[sid]
|
|
return bytes(out)
|
|
|
|
SCH_INTERNAL = ":\\SchLib\\0.schlib"
|
|
PCB_INTERNAL = ":\\PCBLib\\0.pcblib"
|
|
# A known-good single-component .IntLib shipped with the skill, used purely as the OLE container
|
|
# skeleton (all five of its streams get overwritten; only its directory tree + the constant
|
|
# Version.Txt are reused). Nothing of the template part's data survives into the output.
|
|
DEFAULT_TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "assets", "templates", "intlib_container.IntLib")
|
|
|
|
|
|
def _astr(s):
|
|
"""Altium length-prefixed string: uint32(byte_len+1) + byte(byte_len) + utf8 bytes."""
|
|
b = s.encode("utf-8")
|
|
if len(b) > 254:
|
|
raise ValueError(f"string too long for single-byte length prefix: {s[:40]!r}...")
|
|
return struct.pack("<I", len(b) + 1) + bytes([len(b)]) + b
|
|
|
|
|
|
def build_libcrossref(libref, description, footprint, sch_src, pcb_src, model_type="PCBLIB"):
|
|
out = b"\x00"
|
|
out += struct.pack("<I", 1)
|
|
out += _astr(libref)
|
|
out += _astr(SCH_INTERNAL)
|
|
out += struct.pack("<I", 1)
|
|
out += _astr(description)
|
|
out += _astr(sch_src)
|
|
out += struct.pack("<I", 1)
|
|
out += _astr(footprint)
|
|
out += _astr(model_type)
|
|
out += struct.pack("<I", 1)
|
|
out += _astr(PCB_INTERNAL)
|
|
out += _astr(pcb_src)
|
|
return out
|
|
|
|
|
|
def build_parameters_bin(libref, description, footprint, params, npins, npads):
|
|
fields = ["Comment=*", "Component Kind=Standard", f"Description={description}",
|
|
f"Footprint={footprint}", f"Library Reference={libref}"]
|
|
for k, v in params.items():
|
|
if v not in ("", None):
|
|
fields.append(f"{k}={v}")
|
|
fields += [f"Designator={libref}", "Component Type=Standard", f"Pin Count={npins} Height=0"]
|
|
sym = "|".join(fields).encode("utf-8")
|
|
out = b"\x00" + struct.pack("<I", len(sym)) + sym
|
|
pad = f"Pad Count={npads} Height=0".encode("utf-8")
|
|
out += b"\x00" + struct.pack("<I", len(pad) + 1) + pad
|
|
return out
|
|
|
|
|
|
# ---- pull the metadata we need straight out of the embedded .SchLib / .PcbLib ---------------
|
|
|
|
def sch_facts(schlib_path):
|
|
o = olefile.OleFileIO(schlib_path)
|
|
libref = desc = footprint = None
|
|
params = {}
|
|
npins = 0
|
|
for e in o.listdir(streams=True):
|
|
if e[-1].lower() != "data":
|
|
continue
|
|
t = o.openstream(e).read().decode("utf-8", "replace")
|
|
m = re.search(r"\|RECORD=1\|[^\x00]*?LibReference=([^|]+)", t)
|
|
if not m:
|
|
continue
|
|
libref = m.group(1)
|
|
cd = re.search(r"\|ComponentDescription=([^|]*)", t)
|
|
desc = cd.group(1) if cd else ""
|
|
fp = re.search(r"\|RECORD=45\|[^\x00]*?ModelName=([^|]+)\|ModelType=([^|]+)", t)
|
|
footprint = fp.group(1) if fp else None
|
|
for val, nm in re.findall(r"\|RECORD=41\|[^\x00]*?\|Text=([^|]*)\|Name=([^|]+)\|", t):
|
|
if nm not in ("Comment",):
|
|
params[nm] = val
|
|
npins = len(re.findall(r"\|RECORD=2\|", t))
|
|
break
|
|
o.close()
|
|
return libref, desc or "", footprint, params, npins
|
|
|
|
|
|
def pcb_pad_count(pcblib_path, footprint):
|
|
o = olefile.OleFileIO(pcblib_path)
|
|
n = 0
|
|
try:
|
|
for e in o.listdir(streams=True):
|
|
if e[0].lower() == footprint.lower() and e[-1].lower() == "data":
|
|
b = o.openstream(e).read()
|
|
n = b.upper().count(b"TOP") // 2 or len(re.findall(rb"Pad", b))
|
|
finally:
|
|
o.close()
|
|
return n
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--schlib", required=True)
|
|
ap.add_argument("--pcblib", required=True)
|
|
ap.add_argument("--template", default=DEFAULT_TEMPLATE,
|
|
help="a known-good single-component .IntLib to reuse as the OLE container "
|
|
"(defaults to the skill's bundled assets/templates/intlib_container.IntLib)")
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--pads", type=int, default=0, help="override pad count for the search index")
|
|
a = ap.parse_args()
|
|
|
|
# Re-serialise the symbol library through the same FAT-first CFB writer used for the outer
|
|
# container, so the EMBEDDED .schlib has the conventional layout Altium's extractor expects
|
|
# (schlib_write emits a valid but FAT-last OLE; Altium reads that standalone, but its IntLib
|
|
# extractor is stricter — matching the footprint lib's FAT-first layout avoids a read error).
|
|
se, sp, sc = read_container(a.schlib)
|
|
sch = write_cfb(se, sc)
|
|
pcb = open(a.pcblib, "rb").read()
|
|
libref, desc, footprint, params, npins = sch_facts(a.schlib)
|
|
if not libref:
|
|
sys.exit("could not read a LibReference from the .SchLib")
|
|
if not footprint:
|
|
sys.exit("the .SchLib has no RECORD=45 footprint model link — add the footprint model first")
|
|
npads = a.pads or pcb_pad_count(a.pcblib, footprint) or npins
|
|
|
|
entries, paths, content = read_container(a.template)
|
|
by = {"/".join(p): sid for sid, p in paths.items()}
|
|
|
|
def put(name, data):
|
|
matches = [sid for key, sid in by.items() if key.lower() == name.lower()]
|
|
if not matches:
|
|
sys.exit(f"template IntLib has no stream '{name}'")
|
|
content[matches[0]] = data
|
|
|
|
# Altium writes its embedded libs with zlib at DEFAULT level (header 0x789c / FLEVEL=2). Its
|
|
# decompressor rejects other FLEVELs (e.g. level-9's 0x78da) with "Stream read error", so we
|
|
# must match level 6 exactly, not maximise compression.
|
|
put("SchLib/0.schlib", b"\x02" + zlib.compress(sch))
|
|
put("PCBLib/0.pcblib", b"\x02" + zlib.compress(pcb))
|
|
put("LibCrossRef.Txt", build_libcrossref(libref, desc, footprint,
|
|
os.path.basename(a.schlib), os.path.basename(a.pcblib)))
|
|
# find the "Parameters .bin" stream (its name has embedded spaces)
|
|
param_key = next((k for k in by if k.lower().startswith("parameters") and k.lower().endswith(".bin")), None)
|
|
if param_key:
|
|
content[by[param_key]] = build_parameters_bin(libref, desc, footprint, params, npins, npads)
|
|
|
|
blob = write_cfb(entries, content)
|
|
open(a.out, "wb").write(blob)
|
|
|
|
# ---- validate: re-open, decompress the embedded libs, confirm they are intact -------------
|
|
assert olefile.isOleFile(a.out), "output is not a valid OLE"
|
|
o = olefile.OleFileIO(a.out)
|
|
got = {"/".join(e): o.openstream(e).read() for e in o.listdir(streams=True)}
|
|
o.close()
|
|
sch_back = zlib.decompress(got["SchLib/0.schlib"][1:])
|
|
pcb_back = zlib.decompress(got["PCBLib/0.pcblib"][1:])
|
|
ok = (sch_back == sch and pcb_back == pcb
|
|
and olefile.isOleFile(a.out))
|
|
print(f"wrote {a.out}")
|
|
print(f" libref={libref!r} footprint={footprint!r} desc={desc!r}")
|
|
print(f" embedded SchLib round-trip: {'OK' if sch_back==sch else 'MISMATCH'} ({len(sch_back)} B)")
|
|
print(f" embedded PcbLib round-trip: {'OK' if pcb_back==pcb else 'MISMATCH'} ({len(pcb_back)} B)")
|
|
print(f" LibCrossRef has libref+footprint: "
|
|
f"{libref.encode() in got['LibCrossRef.Txt'] and footprint.encode() in got['LibCrossRef.Txt']}")
|
|
print(f" streams: {sorted(got)}")
|
|
if not ok:
|
|
sys.exit("VALIDATION FAILED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|