skill
Adom Altium SchLib
Public Made by Adomby adom
Native Rust parser for Altium .SchLib binary symbol libraries — pins + body to KiCad-shaped JSON, native SVG, .kicad_sym export. The symbol counterpart to altium-pcblib.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
//! Native Rust parser for Altium `.SchLib` (and `.IntLib`'s SchLib stream)
//! schematic-symbol libraries — the symbol counterpart to `altium-pcblib`.
//!
//! Altium `.SchLib` is a Microsoft OLE2 / Compound File Binary container. Each
//! component is a storage holding a `Data` stream of records:
//! * **text records** — `<u16 len><u16 flags>` then `|KEY=VAL|KEY=VAL` ASCII.
//! RECORD=1 is the component header, RECORD=14 a rectangle (body), RECORD=41
//! a parameter.
//! * **binary pin records** — `<u16 len><u16 flags>` then a payload whose first
//! byte is `0x02`. Layout (offsets into the payload), reverse-engineered and
//! validated pin-for-pin against KiCad 10's Altium importer:
//! [14] PinConglomerate (u8) — orientation in bits 0..1
//! [15..17] PinLength (i16)
//! [17..19] Location.X (i16)
//! [19..21] Location.Y (i16)
//! [26..] length-prefixed strings: Name, then Designator
//!
//! Output JSON mirrors `altium-pcblib`'s KiCad-shaped style so the rest of the
//! Adom EDA family consumes both the same way.
use anyhow::{anyhow, bail, Result};
use std::io::{Cursor, Read};
use std::path::Path;
/// Parse an Altium `.SchLib` from a file path → symbol JSON.
pub fn parse_altium_schlib_path(path: &Path) -> Result<serde_json::Value> {
let bytes = std::fs::read(path)
.map_err(|e| anyhow!("read .SchLib {}: {e}", path.display()))?;
parse_altium_schlib_bytes(&bytes)
}
/// Parse an Altium `.SchLib` from raw bytes → symbol JSON.
pub fn parse_altium_schlib_bytes(bytes: &[u8]) -> Result<serde_json::Value> {
if bytes.len() < 8 || bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {
bail!("not an OLE2 compound document (bad magic)");
}
// Altium writes FATs that overshoot the file's real sector count; pad with
// the CFBF free-sector sentinel (0xFF) and re-open. Same fix as altium-pcblib.
let owned;
let cursor = if bytes.len() < 1024 * 1024 {
owned = {
let mut v = bytes.to_vec();
v.resize(1024 * 1024, 0xFF);
v
};
Cursor::new(owned.as_slice())
} else {
Cursor::new(bytes)
};
let mut comp = cfb::CompoundFile::open(cursor).map_err(|e| anyhow!("open OLE2: {e}"))?;
// Each non-housekeeping root storage is a component.
let comps: Vec<String> = comp
.read_root_storage()
.filter_map(|e| {
if !e.is_storage() {
return None;
}
let n = e.name().to_string();
if n.starts_with('\u{1}') || n == "Library" || n == "FileVersionInfo" {
return None;
}
Some(n)
})
.collect();
if comps.is_empty() {
bail!("no component storages found in SchLib");
}
// Single-part library is the common chip-fetcher case: take the first.
let name = &comps[0];
let mut data = Vec::new();
comp.open_stream(format!("/{name}/Data"))
.map_err(|e| anyhow!("open /{name}/Data: {e}"))?
.read_to_end(&mut data)?;
parse_component_stream(name, &data)
}
fn parse_component_stream(comp_name: &str, d: &[u8]) -> Result<serde_json::Value> {
let mut pins: Vec<serde_json::Value> = Vec::new();
let mut description = String::new();
let mut part_count = 1i64;
let mut body: Option<serde_json::Value> = None;
let mut i = 0usize;
while i + 4 <= d.len() {
let plen = u16::from_le_bytes([d[i], d[i + 1]]) as usize;
let is_text = i + 4 < d.len() && d[i + 4] == b'|';
i += 4;
if plen == 0 || i + plen > d.len() {
break;
}
let payload = &d[i..i + plen];
i += plen;
if is_text {
let txt = String::from_utf8_lossy(payload);
let rec = field(&txt, "RECORD").unwrap_or_default();
match rec.as_str() {
"1" => {
description = field(&txt, "ComponentDescription").unwrap_or_default();
part_count = field(&txt, "PartCount").and_then(|v| v.parse().ok()).unwrap_or(1);
}
"14" => {
// Rectangle = the symbol body. Coords are |Location.X|.Y|Corner.X|.Y.
let lx = field(&txt, "Location.X").and_then(|v| v.parse::<f64>().ok());
let ly = field(&txt, "Location.Y").and_then(|v| v.parse::<f64>().ok());
let cx = field(&txt, "Corner.X").and_then(|v| v.parse::<f64>().ok());
let cy = field(&txt, "Corner.Y").and_then(|v| v.parse::<f64>().ok());
if let (Some(lx), Some(ly), Some(cx), Some(cy)) = (lx, ly, cx, cy) {
body = Some(serde_json::json!({
"x1": lx, "y1": ly, "x2": cx, "y2": cy
}));
}
}
_ => {}
}
continue;
}
if payload.first() == Some(&0x02) {
pins.push(parse_pin(payload));
}
}
if pins.is_empty() {
bail!("no pins parsed from component {comp_name}");
}
Ok(serde_json::json!({
"module_name": comp_name,
"description": description,
"part_count": part_count,
"pin_count": pins.len(),
"pins": pins,
"body": body,
"_source": "altium_schlib",
}))
}
/// Decode one binary pin record payload → pin JSON.
fn parse_pin(p: &[u8]) -> serde_json::Value {
let i16at = |o: usize| p.get(o..o + 2).map(|b| i16::from_le_bytes([b[0], b[1]]) as i64).unwrap_or(0);
let conglom = p.get(14).copied().unwrap_or(0);
let length = i16at(15);
let x = i16at(17);
let y = i16at(19);
// strings: Name then Designator, starting at offset 26
let mut j = 26usize;
let name = read_pstr(p, &mut j);
let designator = read_pstr(p, &mut j);
// Orientation: PinConglomerate bits 0..1 → 0/90/180/270°.
let rotation = (conglom & 0x03) as i64 * 90;
serde_json::json!({
"number": designator,
"name": name,
"x": x,
"y": y,
"length": length,
"rotation": rotation,
"conglomerate": conglom,
})
}
/// `<u8 len><bytes>` length-prefixed string.
fn read_pstr(d: &[u8], i: &mut usize) -> String {
if *i >= d.len() {
return String::new();
}
let l = d[*i] as usize;
*i += 1;
if *i + l > d.len() {
return String::new();
}
let s = String::from_utf8_lossy(&d[*i..*i + l]).to_string();
*i += l;
s
}
/// Pull `KEY=VALUE` out of a `|KEY=VAL|KEY=VAL` Altium text record.
fn field(rec: &str, key: &str) -> Option<String> {
let needle = format!("{key}=");
for part in rec.split('|') {
if let Some(v) = part.strip_prefix(&needle) {
return Some(v.trim().to_string());
}
}
None
}
// ------------------------------------------------------------------------
// Native SVG renderer — draws the symbol directly from the parsed pins, the
// same way altium-pcblib renders footprints. No KiCad, no service round-trip.
// ------------------------------------------------------------------------
/// Altium SCH coordinate → mm. Pin pitch is 2560 internal units = 100 mil =
/// 2.54 mm (the schematic-grid standard), so one unit = 2.54/2560 mm.
const MM_PER_UNIT: f64 = 2.54 / 2560.0;
/// Render the parsed symbol to a standalone SVG. Pins are grouped into a left
/// and right bank by X sign and stacked in designator order at a uniform pitch
/// — a clean, readable schematic symbol that preserves which side each pin is
/// on and its name/number (the data that actually matters for comparison).
pub fn render_symbol_svg(sym: &serde_json::Value) -> String {
let module = sym.get("module_name").and_then(|v| v.as_str()).unwrap_or("?");
let empty = vec![];
let pins = sym.get("pins").and_then(|v| v.as_array()).unwrap_or(&empty);
// Split into banks by X sign; preserve native vertical order (top→bottom).
let mut left: Vec<&serde_json::Value> = Vec::new();
let mut right: Vec<&serde_json::Value> = Vec::new();
for p in pins {
let x = p.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
if x < 0 { left.push(p); } else { right.push(p); }
}
let key = |p: &&serde_json::Value| -> i64 { -p.get("y").and_then(|v| v.as_i64()).unwrap_or(0) };
left.sort_by_key(key);
right.sort_by_key(key);
let pitch = 2.54_f64; // mm between pins
let rows = left.len().max(right.len()).max(1) as f64;
let body_w = 38.0_f64;
let body_h = rows * pitch + pitch; // one row of padding top+bottom
let pin_len = 7.62_f64;
let name_pad = 1.4_f64;
let total_w = body_w + 2.0 * pin_len + 44.0; // room for pin names outside? names go inside
let total_h = body_h + 8.0;
let ox = total_w / 2.0; // body centered horizontally
let bx1 = ox - body_w / 2.0;
let bx2 = ox + body_w / 2.0;
let by1 = 4.0;
let by2 = by1 + body_h;
let mut s = String::new();
s.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w:.1} {total_h:.1}" width="{:.0}" height="{:.0}" font-family="monospace">"#,
total_w * 14.0, total_h * 14.0
));
s.push('\n');
s.push_str(&format!(r##"<rect x="0" y="0" width="{total_w:.1}" height="{total_h:.1}" fill="#0d1117"/>"##));
// body
s.push_str(&format!(
r##"<rect x="{bx1:.2}" y="{by1:.2}" width="{:.2}" height="{:.2}" fill="#10212a" stroke="#00b8b1" stroke-width="0.25"/>"##,
bx2 - bx1, by2 - by1
));
s.push('\n');
let draw = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| {
for (idx, p) in bank.iter().enumerate() {
let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
let cy = by1 + pitch + idx as f64 * pitch;
let (x_body, x_tip, anchor, nx) = if is_left {
(bx1, bx1 - pin_len, "start", bx1 + name_pad)
} else {
(bx2, bx2 + pin_len, "end", bx2 - name_pad)
};
// pin line
out.push_str(&format!(
r##"<line x1="{x_body:.2}" y1="{cy:.2}" x2="{x_tip:.2}" y2="{cy:.2}" stroke="#7cb6ff" stroke-width="0.18"/>"##
));
// pin number above the line, near the tip
let numx = if is_left { x_tip + 0.6 } else { x_tip - 0.6 };
let numanchor = if is_left { "start" } else { "end" };
out.push_str(&format!(
r##"<text x="{numx:.2}" y="{:.2}" font-size="1.0" fill="#8b949e" text-anchor="{numanchor}">{}</text>"##,
cy - 0.5, esc(num)
));
// pin name inside the body
let display = altium_name_to_plain(name);
out.push_str(&format!(
r##"<text x="{nx:.2}" y="{:.2}" font-size="1.25" fill="#e6edf3" text-anchor="{anchor}">{}</text>"##,
cy + 0.45, esc(&display)
));
out.push('\n');
}
};
draw(&left, true, &mut s);
draw(&right, false, &mut s);
// header
s.push_str(&format!(
r##"<text x="{ox:.2}" y="{:.2}" font-size="1.5" fill="#00e6dc" text-anchor="middle">{} ({} pins)</text>"##,
by1 - 1.2, esc(module), pins.len()
));
let _ = MM_PER_UNIT;
s.push_str("\n</svg>\n");
s
}
/// Altium overbar/special markup → plain readable text. Altium marks an active-
/// low signal by following each char with a backslash (`C\S\` = C̄S̄); collapse
/// those so a name reads as "CS".
fn altium_name_to_plain(s: &str) -> String {
s.replace('\\', "")
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
// ------------------------------------------------------------------------
// .kicad_sym emitter — for EXPORT only (when the user wants KiCad output).
// Rendering uses the native SVG above; this is the convert-on-export path.
// ------------------------------------------------------------------------
/// Emit a `.kicad_sym` from the parsed symbol. Banks pins left/right like the
/// SVG so the exported KiCad symbol is laid out sensibly.
pub fn to_kicad_sym(sym: &serde_json::Value, lib_name: &str) -> String {
let empty = vec![];
let pins = sym.get("pins").and_then(|v| v.as_array()).unwrap_or(&empty);
let mut left: Vec<&serde_json::Value> = Vec::new();
let mut right: Vec<&serde_json::Value> = Vec::new();
for p in pins {
let x = p.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
if x < 0 { left.push(p); } else { right.push(p); }
}
let key = |p: &&serde_json::Value| -> i64 { -p.get("y").and_then(|v| v.as_i64()).unwrap_or(0) };
left.sort_by_key(key);
right.sort_by_key(key);
let pitch = 2.54_f64;
let rows = left.len().max(right.len()).max(1) as f64;
let half_h = (rows * pitch) / 2.0 + pitch;
let half_w = 19.05_f64;
let pin_len = 5.08_f64;
let mut body = String::new();
let mut emit = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| {
for (idx, p) in bank.iter().enumerate() {
let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
let cy = half_h - pitch - idx as f64 * pitch;
let (px, rot) = if is_left { (-half_w - pin_len, 0) } else { (half_w + pin_len, 180) };
out.push_str(&format!(
" (pin bidirectional line (at {px:.2} {cy:.2} {rot}) (length {pin_len:.2})\n (name \"{}\" (effects (font (size 1.27 1.27)))) (number \"{}\" (effects (font (size 1.27 1.27)))))\n",
kicad_name(name), kesc(num)
));
}
};
emit(&left, true, &mut body);
emit(&right, false, &mut body);
format!(
"(kicad_symbol_lib (version 20211014) (generator altium-schlib)\n (symbol \"{lib_name}\" (in_bom yes) (on_board yes)\n (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{lib_name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (symbol \"{lib_name}_1_1\"\n (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body} )\n )\n)\n",
half_h + 1.27, -half_h - 1.27, -half_w, half_h, half_w, -half_h,
)
}
/// KiCad uses `~{X}` for an overbar; Altium's `X\` per-char → wrap the whole
/// name in one overbar group when it's marked active-low.
fn kicad_name(s: &str) -> String {
if s.contains('\\') {
format!("~{{{}}}", s.replace('\\', ""))
} else {
kesc(s)
}
}
fn kesc(s: &str) -> String {
s.replace('"', "'")
}