skill
Altium PcbLib — native footprint parser
Public Made by Adomby adom
Native Rust parser for Altium .PcbLib binary footprint libraries — used by chipsmith + concur for cross-source agreement.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
//! Native Rust parser for Altium .PcbLib (and .IntLib) footprint libraries.
//!
//! Altium .PcbLib is a Microsoft OLE2 / Compound File Binary container. Each
//! footprint lives in its own top-level storage (e.g. `/D8`, `/SOP50P490X110-10N`)
//! and the interesting stream is `/<name>/Data` — Altium's binary record format
//! laid out as a sequence of typed records (Pad / Track / Arc / Text / ...).
//!
//! This crate extracts every footprint's pad list and emits JSON shaped like
//! KiCad's `parse_kicad_mod`-style output, so downstream tools (chipsmith's
//! cross-source agreement matrix, concur's consensus check) can compare KiCad
//! / Fusion / Altium pads side-by-side.
//!
//! ## Public API
//!
//! - [`parse_altium_pcblib_path`] — read a `.PcbLib` from disk.
//! - [`parse_altium_pcblib_bytes`] — parse from raw bytes (handy for tests
//! and for streams already in memory).
//!
//! Both return a `serde_json::Value` with this shape:
//!
//! ```json
//! {
//! "module_name": "D8",
//! "pad_count": 8,
//! "pads": [{
//! "number": "1", "type": "smd", "shape": "rect",
//! "at": [-2.4638, -1.905, 0.0],
//! "size": [1.9812, 0.5588],
//! "layers": ["F.Cu", "F.Paste", "F.Mask"],
//! "drill": null
//! }],
//! "silk_markers": [],
//! "pin1_silk": { "found": false },
//! "_source": "altium_pcblib",
//! "_alternates": [ /* other footprints in the lib */ ]
//! }
//! ```
//!
//! ## Record format (reverse-engineered from real-world chip libraries)
//!
//! Each footprint's `Data` stream begins with a length-prefixed Pascal-style
//! footprint name:
//!
//! ```text
//! [u32 LE name_block_len][u8 name_pstring_len][name bytes...]
//! ```
//!
//! After the name, records appear back-to-back. Every record starts with a
//! u8 type byte. Different record types have different shapes:
//!
//! - **Track (0x04)** / **Arc (0x01)** / **Region (0x0B)** / **Body (0x0C)** /
//! **Fill (0x06)** / **Via (0x03)**: `[u8 type][u32 LE len][len bytes payload]`.
//!
//! - **Pad (0x02)**: a sequence of 6 length-prefixed sub-blocks following the
//! type byte. Each sub-block is `[u32 LE block_len][block_len bytes]`. The
//! first sub-block holds the pad designator (Pascal string with 1-byte len).
//! The fifth sub-block holds the main pad data — usually 194 or 202 bytes
//! depending on the Altium PcbLib format version. The sixth is typically
//! length-zero (trailing padding).
//!
//! - **Text (0x05)**: two length-prefixed sub-blocks (main payload + style
//! trailer). We don't decode it — it's only consumed to keep the cursor
//! aligned past silkscreen text.
//!
//! ### Pad data block layout (block 5)
//!
//! ```text
//! [0] u8 layer (1 = TOP_CU, 32 = BOTTOM_CU, 21 = TOP_OVERLAY, ...)
//! [1] u8 net/flag (0x08 in v3, 0x0c in v4 — used as a version probe)
//! [2] u8 reserved
//! [3..13] 10 bytes of 0xFF padding (component / net id sentinels)
//! [13..17] i32 LE x position (units = 1 / 10000 mil = 2.54e-6 mm)
//! [17..21] i32 LE y position (Altium Y-up — flip sign for KiCad's Y-down)
//! [21..25] u32 LE x size, top layer
//! [25..29] u32 LE y size, top layer
//! [29..33] u32 LE x size, mid layer (multi-layer thru-hole pads)
//! [33..37] u32 LE y size, mid layer
//! [37..41] u32 LE x size, bottom layer
//! [41..45] u32 LE y size, bottom layer
//! [45..49] u32 LE hole size (0 → SMD; > 0 → thru-hole)
//! [49] u8 shape, top layer (1 round, 2 rect, 3 octagonal, 9 rounded-rect)
//! [50] u8 shape, mid layer
//! [51] u8 shape, bottom layer
//! [52..60] f64 LE rotation in degrees (only present in 202-byte v4 layout)
//! ```
//!
//! Past offset 60 the layout drifts between Altium releases (paste / mask
//! offsets, plated bool, GUIDs, primitive index, …). We don't need any of that
//! for cross-source agreement, so we stop reading.
//!
//! Rotation matters only for the rendered bounding box: KiCad records the
//! "logical" pad shape and rotates it; Altium 194-byte pads bake the rotation
//! into x_size/y_size and store rotation = 0. Both representations cover the
//! same physical region, which is what cross-source agreement actually
//! compares.
use anyhow::{anyhow, bail, Context, Result};
use std::io::{Cursor, Read};
use std::path::Path;
const ALTIUM_UNIT_TO_MM: f64 = 0.00000254; // 1 / 10000 mil → mm
/// Parse an Altium .PcbLib (or .IntLib's PCB lib stream) from a file path.
/// Returns the JSON for the FIRST footprint in the library that has at least
/// one pad. Other footprints, if present, ride along under `_alternates`.
pub fn parse_altium_pcblib_path(path: &Path) -> Result<serde_json::Value> {
let bytes = std::fs::read(path)
.with_context(|| format!("read .PcbLib: {}", path.display()))?;
parse_altium_pcblib_bytes(&bytes)
}
/// Parse an Altium .PcbLib from raw bytes.
pub fn parse_altium_pcblib_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 occasionally writes PcbLib FATs that overshoot the file's actual
// sector count (cfb refuses with "Malformed FAT … FAT has N entries, but
// file has only M sectors"). The unreferenced trailing sectors don't carry
// real data — pad with 0xFF (CFBF "free sector" sentinel) and re-open. 1
// MiB is plenty for any real chip footprint library we've seen.
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}"))?;
// Walk top-level storages. Skip housekeeping ones (`/Library`,
// `/FileVersionInfo`); each remaining storage is a footprint.
let footprint_storages: Vec<String> = comp.read_root_storage()
.filter_map(|ent| {
if !ent.is_storage() { return None; }
let name = ent.name().to_string();
if name == "Library" || name == "FileVersionInfo" { return None; }
// Skip OLE2 metadata streams that start with the 0x01 control char
if name.starts_with('\u{1}') { return None; }
Some(name)
})
.collect();
if footprint_storages.is_empty() {
bail!("no footprint storages found in PcbLib");
}
let mut parsed: Vec<serde_json::Value> = Vec::new();
for name in &footprint_storages {
let stream_path = format!("/{name}/Data");
let mut stream = match comp.open_stream(&stream_path) {
Ok(s) => s,
Err(_) => continue,
};
let mut data = Vec::new();
if stream.read_to_end(&mut data).is_err() { continue; }
match parse_footprint_stream(&data) {
Ok(v) => parsed.push(v),
Err(_e) => {
// Surface the error in the JSON so the caller can flag it,
// but keep walking — other footprints may parse cleanly.
parsed.push(serde_json::json!({
"module_name": name,
"pad_count": 0,
"pads": [],
"silk_markers": [],
"pin1_silk": { "found": false },
"_source": "altium_pcblib",
"_parse_error": _e.to_string(),
}));
}
}
}
if parsed.is_empty() {
bail!("no parseable footprints in PcbLib");
}
// Pick the footprint with the most pads as primary; alternates ride along.
parsed.sort_by(|a, b| {
let pa = a.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
let pb = b.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
pb.cmp(&pa)
});
let mut primary = parsed.remove(0);
if let Some(obj) = primary.as_object_mut() {
obj.insert("_source".to_string(), serde_json::Value::String("altium_pcblib".into()));
if !parsed.is_empty() {
obj.insert("_alternates".to_string(), serde_json::Value::Array(parsed));
}
}
Ok(primary)
}
/// Emit a `.kicad_mod` from a parsed footprint. The pads are already stored in
/// KiCad terms (shape names, F.Cu/F.Paste/F.Mask layers, mm sizes, rotation), so
/// this is a direct serializer — the EXPORT path (rendering uses the native SVG).
pub fn to_kicad_mod(footprint: &serde_json::Value, name: &str) -> String {
let mut out = format!(
"(footprint \"{name}\" (version 20221018) (generator altium-pcblib) (layer \"F.Cu\")\n \
(attr smd)\n \
(fp_text reference \"REF**\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n \
(fp_text value \"{name}\" (at 0 3) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n"
);
let pads = footprint.get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();
for p in &pads {
let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");
let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");
let ptype = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");
let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
let at_s = if rot != 0.0 { format!("{x:.4} {y:.4} {rot}") } else { format!("{x:.4} {y:.4}") };
let layers = p.get("layers").and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|l| l.as_str()).map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" "))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "\"F.Cu\" \"F.Paste\" \"F.Mask\"".into());
if ptype == "thru_hole" {
let drill = p.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.6);
out += &format!(" (pad \"{num}\" thru_hole {shape} (at {at_s}) (size {w:.4} {h:.4}) (drill {drill:.4}) (layers {layers}))\n");
} else {
out += &format!(" (pad \"{num}\" smd {shape} (at {at_s}) (size {w:.4} {h:.4}) (layers {layers}))\n");
}
}
out += ")\n";
out
}
fn parse_footprint_stream(data: &[u8]) -> Result<serde_json::Value> {
if data.len() < 4 {
bail!("stream too short ({} bytes)", data.len());
}
// Footprint name: [u32 LE block_len][u8 pstring_len][bytes...]
let name_block_len = read_u32_le(data, 0)? as usize;
if 4 + name_block_len > data.len() {
bail!("footprint name block overruns stream");
}
let name_block = &data[4..4 + name_block_len];
let module_name = if name_block.is_empty() {
String::new()
} else {
let plen = name_block[0] as usize;
if 1 + plen > name_block.len() {
String::new()
} else {
String::from_utf8_lossy(&name_block[1..1 + plen]).to_string()
}
};
let mut cursor = 4 + name_block_len;
let mut pads: Vec<serde_json::Value> = Vec::new();
let silk_markers: Vec<serde_json::Value> = Vec::new();
// Altium footprints lay pads at the head of the stream, then silk /
// courtyard / fab tracks, then optionally text / region / component-body
// records that we don't model. Walk records until we run out, but stop
// gracefully the moment we lose alignment — at that point we already
// have the pads we need for cross-source agreement, and the silkscreen
// / fab data isn't worth misreading.
while cursor < data.len() {
let record_type = data[cursor];
cursor += 1;
// Number of [u32 len][bytes] sub-blocks expected per record type.
// Reverse-engineered from the bundled samples — most records carry a
// single payload block; pads carry six (designator, layer-name, four
// metadata blocks); text carries two (main + style trailer).
let sub_blocks: usize = match record_type {
0x02 => 6, // Pad
0x05 => 2, // Text
0x01 | 0x03 | 0x04 | 0x06 |
0x09 | 0x0B | 0x0C => 1, // Arc / Via / Track / Fill / Conn / Region / Body
_ => {
// Unknown record — stop without erroring out. Pads come
// first; a stray byte mid-stream is usually a Text
// sub-block we miscounted, not a real new record kind.
break;
}
};
let mut blocks: Vec<&[u8]> = Vec::with_capacity(sub_blocks);
let mut ok = true;
for _ in 0..sub_blocks {
if cursor + 4 > data.len() { ok = false; break; }
let blen = read_u32_le(data, cursor).ok().map(|v| v as usize).unwrap_or(usize::MAX);
// Sanity bound: any sub-block bigger than the whole stream is
// proof we lost alignment. Bail out and keep the pads we have.
if blen > data.len() { ok = false; break; }
cursor += 4;
if cursor + blen > data.len() { ok = false; break; }
blocks.push(&data[cursor..cursor + blen]);
cursor += blen;
}
if !ok {
break;
}
if record_type == 0x02 {
if let Some(pad_json) = decode_pad(&blocks) {
pads.push(pad_json);
}
}
}
let pad_count = pads.len();
let pin1_silk = serde_json::json!({"found": false});
Ok(serde_json::json!({
"module_name": module_name,
"pad_count": pad_count,
"pads": pads,
"silk_markers": silk_markers,
"pin1_silk": pin1_silk,
}))
}
fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {
if blocks.len() < 5 { return None; }
// Block 0: designator pstring (e.g. "01 31" = "1")
let designator = decode_pstring(blocks[0]);
// Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+)
let pd = blocks[4];
if pd.len() < 49 {
return None;
}
let layer_byte = pd[0];
// X / Y in 1/10000 mil units. Altium Y-up → KiCad Y-down (flip sign).
let x_units = read_i32_le(pd, 13)?;
let y_units = read_i32_le(pd, 17)?;
let x_mm = x_units as f64 * ALTIUM_UNIT_TO_MM;
let y_mm = -(y_units as f64 * ALTIUM_UNIT_TO_MM);
let xsize_top = read_u32_le(pd, 21).ok()? as f64 * ALTIUM_UNIT_TO_MM;
let ysize_top = read_u32_le(pd, 25).ok()? as f64 * ALTIUM_UNIT_TO_MM;
// mid / bot sizes available at 29/33/37/41 if we ever need them
let hole_units = read_u32_le(pd, 45).ok()?;
let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM;
let shape_byte = pd[49];
// Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't
// store it (rotation is baked into xsize/ysize). Read what's there; if
// the f64 isn't a finite angle we just default to 0. The bounding box is
// still correct because Altium pre-rotates the size for v3.
let rotation = if pd.len() >= 60 {
let raw = read_f64_le(pd, 52).unwrap_or(0.0);
if raw.is_finite() && raw.abs() < 360.0 + 1e-6 {
// Normalize to [-360, 360); KiCad accepts any angle but typical
// values are 0/90/180/270.
raw
} else {
0.0
}
} else {
0.0
};
// Altium "round" (1) covers both circular pads and stadium-shaped SMD
// pads with rounded ends. KiCad splits those: "circle" for w == h,
// "oval" for w != h.
let shape = match shape_byte {
1 if (xsize_top - ysize_top).abs() < 1e-3 => "circle",
1 => "oval",
_ => altium_shape_to_kicad(shape_byte),
};
let pad_type = if hole_units == 0 {
"smd"
} else {
"thru_hole"
};
let layers = altium_layers_for_pad(layer_byte, pad_type);
let drill = if hole_units == 0 {
serde_json::Value::Null
} else {
serde_json::json!({
"shape": "circle",
"diameter": hole_mm,
})
};
Some(serde_json::json!({
"number": designator,
"type": pad_type,
"shape": shape,
"at": [round6(x_mm), round6(y_mm), round4(rotation)],
"size": [round6(xsize_top), round6(ysize_top)],
"layers": layers,
"drill": drill,
}))
}
fn decode_pstring(block: &[u8]) -> String {
if block.is_empty() { return "?".to_string(); }
let plen = block[0] as usize;
if 1 + plen > block.len() { return "?".to_string(); }
let raw = &block[1..1 + plen];
// Altium pads designators with NUL out to a fixed width; trim trailing NULs.
let trimmed = raw.iter().take_while(|&&b| b != 0).copied().collect::<Vec<_>>();
String::from_utf8_lossy(&trimmed).to_string()
}
fn altium_shape_to_kicad(shape: u8) -> &'static str {
match shape {
1 => "circle", // round — caller specializes to "oval" when w != h
2 => "rect",
3 => "rect", // octagonal — KiCad has no octagonal; rect is closest
9 => "roundrect",
_ => "rect",
}
}
/// Map an Altium layer byte to a KiCad-style layer list. SMD pads claim
/// F.Cu + F.Paste + F.Mask (or B.* on the bottom). Thru-hole pads claim
/// *.Cu + *.Paste + *.Mask so they appear on every layer.
fn altium_layers_for_pad(layer: u8, pad_type: &str) -> Vec<String> {
if pad_type == "thru_hole" {
return vec!["*.Cu".into(), "*.Paste".into(), "*.Mask".into()];
}
let on_bottom = matches!(layer, 32);
if on_bottom {
vec!["B.Cu".into(), "B.Paste".into(), "B.Mask".into()]
} else {
vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()]
}
}
fn read_u32_le(buf: &[u8], off: usize) -> Result<u32> {
if off + 4 > buf.len() { bail!("u32 read past end of buffer at {off}"); }
Ok(u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))
}
fn read_i32_le(buf: &[u8], off: usize) -> Option<i32> {
if off + 4 > buf.len() { return None; }
Some(i32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))
}
fn read_f64_le(buf: &[u8], off: usize) -> Option<f64> {
if off + 8 > buf.len() { return None; }
Some(f64::from_le_bytes([
buf[off], buf[off+1], buf[off+2], buf[off+3],
buf[off+4], buf[off+5], buf[off+6], buf[off+7],
]))
}
fn round6(x: f64) -> f64 { (x * 1_000_000.0).round() / 1_000_000.0 }
fn round4(x: f64) -> f64 { (x * 10_000.0).round() / 10_000.0 }
// ----------------------------------------------------------------------------
// SVG renderer — authoritative debug visualisation
// ----------------------------------------------------------------------------
//
// Downstream tools (chipsmith's overlay, concur's consensus check) interpret
// the parsed JSON. If their renders disagree with the SVG produced here,
// THEY are the bug — this rendering is what the parser actually believes the
// pads to be, drawn directly from `at` / `size` / `rot` / `shape` with no
// further interpretation.
//
// Convention:
// - Coordinates are in millimetres. SVG units = mm. No scaling applied.
// - Y is positive-down (matches both KiCad and our parser's emitted JSON,
// where Altium's Y-up is already flipped at parse time).
// - Rotation is taken straight from `at[2]`. Applied via SVG's
// `transform="rotate(deg cx cy)"` which rotates clockwise as seen on
// screen — same visual effect as KiCad's rotation.
//
// What the SVG shows:
// - Footprint name + pad count in the corner.
// - One <rect>/<circle>/<ellipse> per pad, coloured by layer
// (F.Cu = orange, B.Cu = blue, thru-hole = both).
// - The drill hole as a white circle inside thru-hole pads.
// - Pad number text un-rotated for readability (centred on the pad).
// - Origin crosshair at (0, 0) so you can sanity-check pad placement.
// - viewBox auto-fitted with 1 mm padding around the pad bounding box.
/// Render the parsed footprint JSON as a standalone SVG document. The input
/// must be the value returned by [`parse_altium_pcblib_bytes`] /
/// [`parse_altium_pcblib_path`] — either the primary footprint or one of its
/// `_alternates`.
pub fn render_footprint_svg(footprint: &serde_json::Value) -> String {
let module_name = footprint
.get("module_name").and_then(|v| v.as_str()).unwrap_or("?");
let pads = footprint
.get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();
// Compute the post-rotation axis-aligned bbox of every pad so the
// viewBox covers the whole footprint with a small margin.
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for p in &pads {
if let Some(bb) = pad_post_rotation_bbox(p) {
min_x = min_x.min(bb.0);
min_y = min_y.min(bb.1);
max_x = max_x.max(bb.2);
max_y = max_y.max(bb.3);
}
}
if !min_x.is_finite() {
// No pads with usable geometry — emit an empty placeholder so the
// caller still gets a valid SVG document.
return format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 10 10" width="200" height="200"><text x="0" y="0" font-size="1" text-anchor="middle" font-family="monospace">{module_name}: no pads</text></svg>"#
);
}
let pad = 1.0_f64; // 1 mm margin around the pad cluster
let vb_x = min_x - pad;
let vb_y = min_y - pad;
let vb_w = (max_x - min_x) + 2.0 * pad;
let vb_h = (max_y - min_y) + 2.0 * pad;
// Render each pad. Always 1 mm = 1 unit so callers can pixel-measure.
let px_per_mm = 50.0_f64; // hint to image viewers (overridable via CSS)
let mut body = String::new();
body.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.4} {:.4} {:.4} {:.4}" width="{:.0}" height="{:.0}" font-family="monospace">"#,
vb_x, vb_y, vb_w, vb_h,
vb_w * px_per_mm, vb_h * px_per_mm,
));
body.push('\n');
// Background — pale grid so axes read clearly.
body.push_str(&format!(
r##" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="#fafafa" stroke="none"/>"##,
vb_x, vb_y, vb_w, vb_h,
));
body.push('\n');
// Origin crosshair — 1 mm long arms.
body.push_str(r##" <g stroke="#888" stroke-width="0.04" fill="none">"##);
body.push('\n');
body.push_str(r#" <line x1="-0.5" y1="0" x2="0.5" y2="0"/>"#);
body.push('\n');
body.push_str(r#" <line x1="0" y1="-0.5" x2="0" y2="0.5"/>"#);
body.push('\n');
body.push_str(r#" </g>"#);
body.push('\n');
// Pads.
for p in &pads {
body.push_str(&render_pad_svg(p));
}
// Header text — top-left corner of viewBox.
body.push_str(&format!(
r##" <text x="{:.4}" y="{:.4}" font-size="0.5" fill="#222">{} ({} pads)</text>"##,
vb_x + 0.2, vb_y + 0.6, escape_xml(module_name), pads.len(),
));
body.push('\n');
body.push_str("</svg>\n");
body
}
/// Convenience: render the primary footprint plus every `_alternates` entry,
/// one above the next, into a single SVG so a reviewer can flip through
/// every variant a `.PcbLib` declares.
pub fn render_all_footprints_svg(parsed: &serde_json::Value) -> String {
// Collect primary + every alternate as separate SVG bodies, then stack
// them vertically inside one outer <svg>.
let mut footprints: Vec<&serde_json::Value> = vec![parsed];
if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {
footprints.extend(alts.iter());
}
// Render each independently, then string-concat into a vertical stack.
let inner: Vec<String> = footprints.iter().map(|fp| render_footprint_svg(fp)).collect();
// Crude but effective: inline each as <svg ... y="offset"> inside an
// outer wrapper. SVG nesting works in browsers and most viewers.
let mut out = String::new();
let panel_height = 400.0_f64;
let panel_gap = 40.0_f64;
let total_h = footprints.len() as f64 * (panel_height + panel_gap);
out.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="{:.0}">"#,
total_h,
));
out.push('\n');
for (i, svg) in inner.iter().enumerate() {
let y = i as f64 * (panel_height + panel_gap);
out.push_str(&format!(
r#" <svg x="0" y="{:.0}" width="800" height="{:.0}" preserveAspectRatio="xMidYMid meet">"#,
y, panel_height,
));
// strip the outer <svg> tag from the inner doc so we just splice
// its content in.
let trimmed = svg
.trim_start_matches(|c: char| c != '>').trim_start_matches('>')
.trim_end()
.trim_end_matches("</svg>");
out.push_str(trimmed);
out.push_str("</svg>\n");
}
out.push_str("</svg>\n");
out
}
fn render_pad_svg(p: &serde_json::Value) -> String {
let number = p.get("number").and_then(|v| v.as_str()).unwrap_or("?");
let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");
let pad_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");
let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let cx = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let cy = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
// Layer colour: F.Cu → warm orange, B.Cu → cool blue, thru-hole → both.
let on_back = p.get("layers").and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|l| l.as_str() == Some("B.Cu")))
.unwrap_or(false);
let fill = if pad_type == "thru_hole" {
"#a8d4a8" // soft green for thru-hole
} else if on_back {
"#7cb6ff"
} else {
"#ff9a4a"
};
let stroke = "#222";
// Rotation: SVG's `rotate(angle cx cy)` rotates clockwise on screen
// when applied to a Y-down coordinate system, which matches how KiCad
// displays its rotated pads. So we use the angle as-is.
let rot_attr = if rot.abs() > 1e-6 {
format!(r#" transform="rotate({:.4} {:.4} {:.4})""#, rot, cx, cy)
} else {
String::new()
};
let mut out = String::new();
out.push_str(" <g>\n");
match shape {
"circle" => {
let r = w.max(h) / 2.0;
out.push_str(&format!(
r#" <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
cx, cy, r, fill, stroke, rot_attr,
));
out.push('\n');
}
"oval" => {
// KiCad "oval" = stadium: rect with semicircle ends. Approximate
// with rounded-rect rx = min(w, h)/2.
let r = w.min(h) / 2.0;
out.push_str(&format!(
r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,
));
out.push('\n');
}
"roundrect" => {
let r = (w.min(h) * 0.25).max(0.05);
out.push_str(&format!(
r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,
));
out.push('\n');
}
_ => {
// rect (or unknown — draw as rect)
out.push_str(&format!(
r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,
cx - w / 2.0, cy - h / 2.0, w, h, fill, stroke, rot_attr,
));
out.push('\n');
}
}
// Drill hole — drawn AFTER the pad body so it sits on top.
if let Some(drill) = p.get("drill") {
if let Some(d) = drill.get("diameter").and_then(|v| v.as_f64()) {
out.push_str(&format!(
r##" <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#fff" stroke="{}" stroke-width="0.02"/>"##,
cx, cy, d / 2.0, stroke,
));
out.push('\n');
}
}
// Pad number — un-rotated so it stays readable regardless of rot.
let label_size = (w.min(h) * 0.5).clamp(0.15, 0.6);
out.push_str(&format!(
r##" <text x="{:.4}" y="{:.4}" font-size="{:.4}" text-anchor="middle" dominant-baseline="middle" fill="#111">{}</text>"##,
cx, cy, label_size, escape_xml(number),
));
out.push('\n');
out.push_str(" </g>\n");
out
}
/// Compute the axis-aligned bounding box of a pad after applying its rotation.
/// Returns (min_x, min_y, max_x, max_y) in mm.
fn pad_post_rotation_bbox(p: &serde_json::Value) -> Option<(f64, f64, f64, f64)> {
let at = p.get("at").and_then(|v| v.as_array())?;
let size = p.get("size").and_then(|v| v.as_array())?;
let cx = at.first()?.as_f64()?;
let cy = at.get(1)?.as_f64()?;
let rot_deg = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
let w = size.first()?.as_f64()?;
let h = size.get(1)?.as_f64()?;
let theta = rot_deg.to_radians();
let (s, c) = (theta.sin(), theta.cos());
// Half-extents along rotated axes — corners are at ±(w/2)*(c, s) ± (h/2)*(-s, c)
let hx = (w / 2.0 * c).abs() + (h / 2.0 * s).abs();
let hy = (w / 2.0 * s).abs() + (h / 2.0 * c).abs();
Some((cx - hx, cy - hy, cx + hx, cy + hy))
}
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,
/// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad
/// convention.
#[test]
fn lm358d_decodes_eight_pads_pad1_at_top_left() {
const LM358D_PCBLIB: &[u8] = include_bytes!(
"../../chip-fetcher/library/LM358D/LM358D.PcbLib"
);
let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)
.expect("parse LM358D PcbLib");
let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);
assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");
let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");
let pad1 = pads.iter().find(|p| {
p.get("number").and_then(|v| v.as_str()) == Some("1")
}).expect("pad 1");
let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");
let x = at[0].as_f64().unwrap();
let y = at[1].as_f64().unwrap();
let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");
let w = size[0].as_f64().unwrap();
let h = size[1].as_f64().unwrap();
// Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56
assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");
assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");
assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");
assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");
let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers");
assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));
}
}