#!/usr/bin/env python3 """Write Vecmocon's mandatory parameters directly INTO an Altium schematic-library (.SchLib) file, in pure Python — no Altium needed. Reading a value out of a .SchLib is a plain stream read (see altium_refs.py). Writing is harder: parameters live as text records inside the component's ``Data`` stream, and adding/removing them changes that stream's size, which means rebuilding the whole OLE compound container around it. This script does exactly that while **preserving every original byte except the Data stream(s) it must change** — all other streams (FileHeader, Storage, …), the directory tree, the Altium CLSIDs and timestamps are copied through untouched, and only the size/offset fields that must move are recomputed. What it does to the target component's Data stream: - removes the Ultra-Librarian default params ``Manufacturer_Name`` / ``Manufacturer_Part_Number`` (they duplicate the SOP ``Manufacturer`` / ``Manufacturer Part``), the UL ``Copyright`` notice, and the UL ``Component_Type`` (Vecmocon adds its own spaced ``Component Type`` = Class instead); override this default set with a "remove" list in params.json, - adds/updates the SOP parameters from params.json (see references/schlib_parameters.md), - sets the component's ComponentDescription (RECORD=1) from params.json "description" — the strict Description string from references/description_format.md, the SAME text that goes in the part's Excel Description column. Mandatory: a symbol must never ship with Altium's "No Description Available" placeholder, - sets the Comment record's Text from params.json "comment" (SOP §4: Comment = the MPN), patching that record in place so its position/colour/font are preserved, - leaves pins, graphics and all other records exactly as they were. Usage: python schlib_write.py --schlib IN.SchLib --params params.json --out OUT.SchLib params.json (same shape altium_params.py uses): {"component":"JMK105BJ105KV-F", # LibRef / component-storage name; omit -> all comps "description":"Capacitor_CER_1uF_10v_±10%_0402_x5r", # -> ComponentDescription; == Excel Description "comment":"JMK105BJ105KV-F", # -> Comment record Text (the MPN) "parameters":{"Value":"1u","Manufacturer":"Taiyo Yuden","Component Type":"Capacitor", ...}, "remove":["Manufacturer_Name","Manufacturer_Part_Number","Copyright","Component_Type"]} # optional; default IMPORTANT: this writes Altium's own binary format from outside Altium. It is validated to re-open as a well-formed OLE with every other stream byte-identical, but ALWAYS open the result in Altium once to confirm it loads before relying on it. """ import argparse, json, re, struct, sys, hashlib import olefile FREESECT=0xFFFFFFFF; ENDOFCHAIN=0xFFFFFFFE; FATSECT=0xFFFFFFFD SEC=512; MINI=64; CUTOFF=4096 # Ultra-Librarian defaults we strip: the two that duplicate the SOP Manufacturer / Manufacturer # Part, the UL "Copyright" notice (Vecmocon symbols don't carry it), and the UL "Component_Type" # (underscore) — Vecmocon adds its own spaced "Component Type" = the part's Class instead. DEFAULT_REMOVE=["Manufacturer_Name", "Manufacturer_Part_Number", "Copyright", "Component_Type"] def le16(b,o): return struct.unpack('path, sid->content-bytes).""" raw=open(path,'rb').read() dir_start=le32(raw,48) difat=[le32(raw,76+4*i) for i in range(109)] fat=[] for s in difat: if s>=0xFFFFFFFC: continue sec=raw[512+s*SEC:512+(s+1)*SEC] fat+=[struct.unpack('=2 else '' paths={} def walk(sid, prefix): if sid>=0xFFFFFFFC or sid>=len(entries): return e=entries[sid] walk(le32(e,68), prefix) # left nm=name_of(e); paths[sid]=prefix+[nm] if e[66]==1: # storage -> recurse children walk(le32(e,76), prefix+[nm]) walk(le32(e,72), prefix) # right walk(le32(entries[0],76), []) # from root's child ole=olefile.OleFileIO(path) bypath={'/'.join(e):ole.openstream(e).read() for e in ole.listdir(streams=True)} ole.close() content={sid:bypath['/'.join(p)] for sid,p in paths.items() if '/'.join(p) in bypath} return entries, paths, content # ----------------------------------------------------------------- edit a Data stream def _uid(name): h=''.join(c for c in hashlib.md5(name.encode()).hexdigest().upper() if c.isalpha()) return (h+"ABCDEFGH")[:8] def _param_record(idx, name, value): s=(f"|RECORD=41|IndexInSheet={idx}|OwnerPartId=1|Justification=4|FontID=2|IsHidden=T" f"|Text={value}|Name={name}|UniqueID={_uid(name)}") payload=s.encode('latin-1')+b'\x00' return struct.pack(', so any edit that changes the payload length must rewrite the prefix.""" payload=text.encode('latin-1')+b'\x00' return struct.pack('=... up to the next | (or end of record). Adds the field if absent.""" pat=re.compile(r'\|'+re.escape(field)+r'=[^|]*') if pat.search(text): return pat.sub('|'+field+'='+value, text, count=1) return text+'|'+field+'='+value def _leading_text_records(data): """Split Data into (list of leading text-record byte-blocks, tail bytes). The leading run is the contiguous |RECORD=…| text records at the start (RECORD=1 + the parameter block); it stops at the first non-text record (the binary pin records), which — with everything after — is returned untouched as the tail.""" recs=[]; i=0 while i+4<=len(data): L=struct.unpack('len(data): break payload=data[i+4:i+4+L] if payload[-1:]!=b'\x00' or not payload[:8].startswith(b'|RECORD='): break recs.append(data[i:i+4+L]); i+=4+L return recs, data[i:] def _rec_name(block): t=block[4:-1].decode('latin-1') return t.split('|Name=')[1].split('|')[0] if '|Name=' in t else None def edit_data(data, params, remove, description=None, comment=None): """Return a new Data stream: drop `remove` params, drop any SOP-name params (re-added fresh), keep everything else, then append the SOP params. Pins/graphics/tail untouched. `description` (when given) overwrites ComponentDescription on the RECORD=1 header — this is the strict Description string, the same text as the part's Excel Description column, and is required on every Vecmocon symbol. `comment` overwrites the Comment record's Text (the MPN). Both are patched IN PLACE so the records keep their other fields (Comment keeps its Location/Color/FontID), unlike the parameter block which is dropped and re-added.""" leading, tail = _leading_text_records(data) sop_names=set(params) kept=[] for blk in leading: nm=_rec_name(blk) if nm is not None and (nm in remove or nm in sop_names): continue # drop UL duplicates + stale SOP copies txt=blk[4:-1].decode('latin-1') if description is not None and txt.startswith('|RECORD=1|'): blk=_reframe(_sub_field(txt, 'ComponentDescription', description)) elif comment is not None and nm=='Comment' and '|RECORD=41|' in txt: blk=_reframe(_sub_field(txt, 'Text', comment)) kept.append(blk) added=[_param_record(20+i, nm, val) for i,(nm,val) in enumerate(params.items())] return b''.join(kept)+b''.join(added)+tail # ----------------------------------------------------------------- rebuild the container def _set(e, off, val, n): e[off:off+n]=val.to_bytes(n,'little') def rebuild(entries, content): """Rebuild an OLE compound file from raw directory entries + {sid: content}. Small streams (<4096) go in the mini-stream; big streams get their own FAT sectors. Directory-entry bytes are preserved; only each entry's start-sector and size are patched.""" minis=[sid for sid,c in content.items() if len(c)=CUTOFF] 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(' n_fat*SEC: n_fat+=1 fat_secs=list(range(cur,cur+n_fat)); total=cur+n_fat if n_fat>109: sys.exit("too many FAT sectors for a header-only DIFAT (file unexpectedly large)") # patch directory entries: start-sector + size for sid in bigs: _set(entries[sid],116,big_secs[sid][0][0],4); entries[sid][120:128]=struct.pack('=2 and p[-1].lower()=="data" and sid in content: if component is None or p[-2]==component: targets.append(sid) if not targets: sys.exit(f"no component Data stream found" + (f" for '{component}'" if component else "")) for sid in targets: content[sid]=edit_data(content[sid], fields, remove, description, comment) blob=rebuild(entries, content) open(out,'wb').write(blob) # self-check: re-open and confirm it's a valid OLE with the params present if not olefile.isOleFile(out): sys.exit("ERROR: rebuilt file is not a valid OLE — aborting") ole=olefile.OleFileIO(out) ok=True for e in ole.listdir(streams=True): if e[-1].lower()=="data": t=ole.openstream(e).read().decode('latin-1','ignore') for nm in fields: if f"|Name={nm}|" not in t and f"|Name={nm}\x00" not in t and f"Name={nm}" not in t: ok=False if description and f"|ComponentDescription={description}|" not in t \ and not t.split("|ComponentDescription=")[-1].startswith(description): ok=False ole.close() n_comp=len(targets) extra=[] if description: extra.append(f"description={description!r}") if comment: extra.append(f"comment={comment!r}") print(f"wrote {out} ({len(fields)} params into {n_comp} component(s); removed {remove}" + ("; " + ", ".join(extra) if extra else "") + ") " f"{'[self-check OK]' if ok else '[WARN: verify params]'}") def main(): ap=argparse.ArgumentParser() ap.add_argument("--schlib", required=True) ap.add_argument("--params", required=True) ap.add_argument("--out", required=True) a=ap.parse_args() write_params(a.schlib, json.load(open(a.params, encoding="utf-8")), a.out) if __name__ == "__main__": main()