app
Adom Schematic
Public Made by Adomby adom
Interactive schematic viewer: your EDA's own render plus per-symbol highlighting and live MPN, stock and price on hover
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
//! Altium `.SchDoc` schematic parser + renderer.
//!
//! A SchDoc is an OLE2 compound file whose `FileHeader` stream is a flat list of
//! `[u32 len][ASCII |KEY=VAL|…]` records — no binary decoding needed at all.
//! Every record has a numeric `RECORD` type; the ones we draw:
//!
//! 1 Component 2 Pin 4 Label 6 Polyline 7 Polygon
//! 8 Ellipse 12 Arc 13 Line 14 Rectangle 17 Power port
//! 25 Net label 27 Wire 29 Junction 34 Designator 41 Parameter
//!
//! Child primitives (pins, graphics, designators) point at their component with
//! `OwnerIndex`, and — crucially — their coordinates are already ABSOLUTE sheet
//! coordinates, so no per-symbol transform is needed.
//!
//! Units: Altium schematic coordinates are 1/100 inch (10 mil) with an optional
//! `_Frac` field out of 100000. Y is up, ours is down, so Y is negated.
//!
//! Altium has no headless renderer (no CLI at all), so we draw the sheet
//! ourselves in Altium's own colour convention and tag every primitive with its
//! component — the frontend's halo highlight then works unchanged.
use crate::sch_parse::{Comp, Sheet};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read};
/// 10 mil -> mm
const U: f64 = 0.254;
pub fn looks_like_altium(bytes: &[u8]) -> bool {
bytes.len() > 8 && bytes[..8] == [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
type Rec = HashMap<String, String>;
fn kv(rec: &str) -> Rec {
rec.trim_matches('|')
.split('|')
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_uppercase(), v.to_string()))
.collect()
}
fn s<'a>(r: &'a Rec, k: &str) -> &'a str { r.get(k).map(|v| v.as_str()).unwrap_or("") }
fn i(r: &Rec, k: &str) -> i64 { s(r, k).parse().unwrap_or(0) }
/// A coordinate: `<KEY>` in 10-mil units plus optional `<KEY>_FRAC` out of 100000.
fn coord(r: &Rec, k: &str) -> f64 {
let base = i(r, k) as f64;
let frac = i(r, &format!("{k}_FRAC")) as f64 / 100_000.0;
(base + frac) * U
}
fn cx(r: &Rec, k: &str) -> f64 { coord(r, k) }
fn cy(r: &Rec, k: &str) -> f64 { -coord(r, k) }
// Altium's default schematic colours (BGR ints in the file; we just use its palette)
const C_WIRE: &str = "#0000c8"; // wires: blue
const C_SYM: &str = "#800000"; // symbol graphics: dark red
const C_PIN: &str = "#800000";
const C_DESIG: &str = "#800000";
const C_PARAM: &str = "#0000c8";
const C_NETLBL: &str = "#0000c8";
pub fn render_sheet(bytes: &[u8]) -> Result<(String, Sheet), String> {
let mut cf = cfb::CompoundFile::open(Cursor::new(bytes.to_vec()))
.map_err(|e| format!("altium .SchDoc: not a compound file ({e})"))?;
let mut raw = Vec::new();
cf.open_stream("/FileHeader")
.map_err(|e| format!("altium .SchDoc: no FileHeader ({e})"))?
.read_to_end(&mut raw)
.map_err(|e| format!("altium .SchDoc: {e}"))?;
// [u32 len][ascii] repeated
let mut recs: Vec<Rec> = Vec::new();
let mut off = 0usize;
while off + 4 <= raw.len() {
let ln = u32::from_le_bytes([raw[off], raw[off + 1], raw[off + 2], raw[off + 3]]) as usize;
off += 4;
if ln == 0 || off + ln > raw.len() { break; }
let txt = String::from_utf8_lossy(&raw[off..off + ln]).trim_end_matches('\0').to_string();
recs.push(kv(&txt));
off += ln;
}
if recs.is_empty() { return Err("altium .SchDoc: no records".into()); }
// ── components, by record ordinal (that's what OwnerIndex references) ────
// value comes from the "Comment" parameter, footprint from the implementation
// OwnerIndex is 0-based over records EXCLUDING the leading HEADER record,
// so a component sitting at recs[29] is referenced as OwnerIndex=28.
let mut comp_of: HashMap<usize, usize> = HashMap::new(); // owner-index -> comps idx
let mut comps: Vec<Comp> = Vec::new();
for (idx, r) in recs.iter().enumerate() {
if s(r, "RECORD") != "1" { continue; }
comp_of.insert(idx.saturating_sub(1), comps.len());
comps.push(Comp {
reference: String::new(), // from the RECORD=34 designator
value: String::new(),
footprint: String::new(),
lcsc: String::new(),
description: s(r, "COMPONENTDESCRIPTION").to_string(),
datasheet: String::new(),
x: cx(r, "LOCATION.X"), y: cy(r, "LOCATION.Y"),
bbox: [f64::MAX, f64::MAX, f64::MIN, f64::MIN],
pin_count: 0,
regions: Vec::new(),
});
}
// owner lookup for any child record
let owner = |r: &Rec| -> Option<usize> {
let oi = s(r, "OWNERINDEX");
if oi.is_empty() { return None; }
oi.parse::<usize>().ok().and_then(|k| comp_of.get(&k).copied())
};
// ── designator / parameters attach identity to components ───────────────
for r in &recs {
match s(r, "RECORD") {
"34" => {
if let Some(c) = owner(r) { comps[c].reference = s(r, "TEXT").to_string(); }
}
"41" => {
if let Some(c) = owner(r) {
let name = s(r, "NAME").to_uppercase();
let text = s(r, "TEXT").to_string();
match name.as_str() {
"COMMENT" | "VALUE" => if comps[c].value.is_empty() { comps[c].value = text },
"HELPURL" | "DATASHEET" | "DATASHEETURL" => comps[c].datasheet = text,
n if n.contains("LCSC") || n.contains("JLC") => comps[c].lcsc = text,
_ => {}
}
}
}
"45" => {
// implementation: footprint model
if let Some(c) = owner(r) {
if comps[c].footprint.is_empty() { comps[c].footprint = s(r, "MODELNAME").to_string(); }
}
}
_ => {}
}
}
let mut body = String::new();
let mut ext: Vec<(f64, f64)> = Vec::new();
// per-component extents, so the halo covers exactly that symbol
let mut cext: Vec<Vec<(f64, f64)>> = vec![Vec::new(); comps.len()];
let mut fit = |pts: &[(f64, f64)], c: Option<usize>, ext: &mut Vec<(f64, f64)>, cext: &mut Vec<Vec<(f64, f64)>>| {
ext.extend_from_slice(pts);
if let Some(ci) = c { cext[ci].extend_from_slice(pts); }
};
for r in &recs {
let rt = s(r, "RECORD");
let c = owner(r);
let tag = match c {
Some(ci) if !comps[ci].reference.is_empty() => format!(r##" data-cref="{}""##, esc(&comps[ci].reference)),
_ => String::new(),
};
match rt {
// pin: a stub from its root, direction from PinConglomerate bits 0-1
"2" => {
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
let len = i(r, "PINLENGTH") as f64 * U;
let dir = (i(r, "PINCONGLOMERATE") & 0x03) as u8;
let (x2, y2) = match dir {
0 => (x + len, y),
1 => (x, y - len),
2 => (x - len, y),
_ => (x, y + len),
};
if let Some(ci) = c { comps[ci].pin_count += 1; }
fit(&[(x, y), (x2, y2)], c, &mut ext, &mut cext);
let _ = write!(body, r##"<line{} x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.15"/>"##,
tag, x, y, x2, y2, C_PIN);
// Pin NAME sits inside the body (opposite the stub) and the pin
// DESIGNATOR just outside it — same convention as KiCad/Altium.
// PinConglomerate bit 3 = designator visible, bit 4 = name visible.
let cong = i(r, "PINCONGLOMERATE");
let (name, desig) = (s(r, "NAME"), s(r, "DESIGNATOR"));
let gap = 0.5;
// unit vector along the stub, so we can step inward / outward
let (ux, uy) = match dir { 0 => (1.0, 0.0), 1 => (0.0, -1.0), 2 => (-1.0, 0.0), _ => (0.0, 1.0) };
if cong & 0x10 != 0 && !name.is_empty() {
// inward from the root = into the symbol body
let (nx, ny) = (x - ux * gap, y - uy * gap);
let anchor = if ux > 0.5 { "end" } else if ux < -0.5 { "start" } else { "middle" };
let _ = write!(body, r##"<text{} x="{:.3}" y="{:.3}" font-size="1.8" fill="{}" text-anchor="{}" dominant-baseline="middle">{}</text>"##,
tag, nx, ny, C_PIN, anchor, esc(name));
if let Some(ci) = c { cext[ci].push((nx, ny)); }
}
if cong & 0x08 != 0 && !desig.is_empty() {
// just above the outer end of the stub
let (dx2, dy2) = (x2 - ux * gap, y2 - uy * gap - 0.4);
let anchor = if ux > 0.5 { "end" } else if ux < -0.5 { "start" } else { "middle" };
let _ = write!(body, r##"<text{} x="{:.3}" y="{:.3}" font-size="2.1" fill="{}" text-anchor="{}">{}</text>"##,
tag, dx2, dy2, C_DESIG, anchor, esc(desig));
if let Some(ci) = c { cext[ci].push((dx2, dy2)); }
}
}
// polyline / polygon: X1..Xn, Y1..Yn
"6" | "7" => {
let n = i(r, "LOCATIONCOUNT") as usize;
let mut pts = Vec::new();
for k in 1..=n {
let xk = format!("X{k}");
let yk = format!("Y{k}");
if !r.contains_key(&xk) { break; }
pts.push((coord(r, &xk), -coord(r, &yk)));
}
if pts.len() < 2 { continue; }
let mut d = String::new();
for (j, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{} {:.3} {:.3} ", if j == 0 { "M" } else { "L" }, x, y);
}
if rt == "7" { d.push('Z'); }
fit(&pts, c, &mut ext, &mut cext);
let fill = if rt == "7" { C_SYM } else { "none" };
let _ = write!(body, r##"<path{} d="{}" fill="{}" fill-opacity="0.2" stroke="{}" stroke-width="0.15"/>"##, tag, d, fill, C_SYM);
}
// line
"13" => {
let (x1, y1) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
let (x2, y2) = (cx(r, "CORNER.X"), cy(r, "CORNER.Y"));
fit(&[(x1, y1), (x2, y2)], c, &mut ext, &mut cext);
let _ = write!(body, r##"<line{} x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.15"/>"##,
tag, x1, y1, x2, y2, C_SYM);
}
// rectangle (10 = rounded)
"14" | "10" => {
let (x1, y1) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
let (x2, y2) = (cx(r, "CORNER.X"), cy(r, "CORNER.Y"));
fit(&[(x1, y1), (x2, y2)], c, &mut ext, &mut cext);
let _ = write!(body, r##"<rect{} x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#fffbc8" fill-opacity="0.7" stroke="{}" stroke-width="0.15"/>"##,
tag, x1.min(x2), y1.min(y2), (x2 - x1).abs(), (y2 - y1).abs(), C_SYM);
}
// ellipse / arc
"8" | "12" | "11" => {
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
let rr = coord(r, "RADIUS").max(coord(r, "SECONDARYRADIUS")).max(0.2);
fit(&[(x - rr, y - rr), (x + rr, y + rr)], c, &mut ext, &mut cext);
let _ = write!(body, r##"<circle{} cx="{:.3}" cy="{:.3}" r="{:.3}" fill="none" stroke="{}" stroke-width="0.15"/>"##,
tag, x, y, rr, C_SYM);
}
// wire (net) — the sheet's connectivity
"27" => {
let n = i(r, "LOCATIONCOUNT") as usize;
let mut pts = Vec::new();
for k in 1..=n {
let xk = format!("X{k}");
if !r.contains_key(&xk) { break; }
pts.push((coord(r, &xk), -coord(r, &format!("Y{k}"))));
}
if pts.len() < 2 { continue; }
let mut d = String::new();
for (j, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{} {:.3} {:.3} ", if j == 0 { "M" } else { "L" }, x, y);
}
ext.extend_from_slice(&pts);
let _ = write!(body, r##"<path class="adom-wire" d="{}" fill="none" stroke="{}" stroke-width="0.2" stroke-linecap="round"/>"##, d, C_WIRE);
}
// junction
"29" => {
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
ext.push((x, y));
let _ = write!(body, r##"<circle cx="{:.3}" cy="{:.3}" r="0.4" fill="{}"/>"##, x, y, C_WIRE);
}
// power port: stem + the actual GND / earth / bar glyph, then its text
"17" => {
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
// Orientation: 0 right, 1 up, 2 left, 3 down (our Y points down)
let (dx, dy) = match i(r, "ORIENTATION") { 0 => (1.0, 0.0), 1 => (0.0, -1.0), 2 => (-1.0, 0.0), _ => (0.0, 1.0) };
let (px, py) = (-dy, dx); // perpendicular, for the bars
let stem = 2.0;
let (ex2, ey2) = (x + dx * stem, y + dy * stem);
ext.push((x, y)); ext.push((ex2 + px * 2.0, ey2 + py * 2.0)); ext.push((ex2 - px * 2.0, ey2 - py * 2.0));
let _ = write!(body, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.15"/>"##,
x, y, ex2, ey2, C_NETLBL);
let bar = |out: &mut String, along: f64, half: f64| {
let (bx, by) = (x + dx * along, y + dy * along);
let _ = write!(out, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.18"/>"##,
bx + px * half, by + py * half, bx - px * half, by - py * half, C_NETLBL);
};
match i(r, "STYLE") {
// 4 = Power Ground: three shortening bars
4 => { bar(&mut body, stem, 1.6); bar(&mut body, stem + 0.55, 1.0); bar(&mut body, stem + 1.1, 0.45); }
// 5 = Signal Ground: solid triangle
5 => {
let (ax, ay) = (x + dx * (stem + 1.6), y + dy * (stem + 1.6));
let _ = write!(body, r##"<path d="M {:.3} {:.3} L {:.3} {:.3} L {:.3} {:.3} Z" fill="{}"/>"##,
ex2 + px * 1.5, ey2 + py * 1.5, ex2 - px * 1.5, ey2 - py * 1.5, ax, ay, C_NETLBL);
}
// 6 = Earth: one bar plus hatch strokes
6 => {
bar(&mut body, stem, 1.6);
for k in 0..3 {
let o = -1.0 + k as f64;
let (sx, sy) = (x + dx * stem + px * o, y + dy * stem + py * o);
let _ = write!(body, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.15"/>"##,
sx, sy, sx + dx * 0.9 - px * 0.6, sy + dy * 0.9 - py * 0.6, C_NETLBL);
}
}
// 1 = Arrow
1 => {
let (ax, ay) = (x + dx * (stem + 1.0), y + dy * (stem + 1.0));
let _ = write!(body, r##"<path d="M {:.3} {:.3} L {:.3} {:.3} L {:.3} {:.3} Z" fill="{}"/>"##,
ex2 + px * 0.8, ey2 + py * 0.8, ex2 - px * 0.8, ey2 - py * 0.8, ax, ay, C_NETLBL);
}
// 0 = Circle
0 => { let _ = write!(body, r##"<circle cx="{:.3}" cy="{:.3}" r="0.6" fill="none" stroke="{}" stroke-width="0.15"/>"##, ex2, ey2, C_NETLBL); }
// 2 = Bar (and anything else)
_ => bar(&mut body, stem, 1.6),
}
if s(r, "SHOWNETNAME") == "T" && !s(r, "TEXT").is_empty() {
let t = s(r, "TEXT");
let (tx, ty) = (x + dx * (stem + 2.4), y + dy * (stem + 2.4));
ext.push((tx, ty));
let _ = write!(body, r##"<text x="{:.3}" y="{:.3}" font-size="2.1" fill="{}" text-anchor="middle" dominant-baseline="middle">{}</text>"##,
tx, ty, C_NETLBL, esc(t));
}
}
// designator / net label / free label
"34" | "25" | "4" => {
let text = if rt == "17" { s(r, "TEXT") } else { s(r, "TEXT") };
if text.is_empty() { continue; }
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
let color = match rt { "34" => C_DESIG, "25" | "17" => C_NETLBL, _ => C_PARAM };
ext.push((x, y)); ext.push((x + text.chars().count() as f64 * 0.9, y - 1.6));
if let Some(ci) = c { cext[ci].push((x, y)); cext[ci].push((x + text.chars().count() as f64 * 0.9, y - 1.6)); }
let _ = write!(body, r##"<text{} x="{:.3}" y="{:.3}" font-size="2.2" fill="{}">{}</text>"##, tag, x, y, color, esc(text));
}
// visible parameter (e.g. the value under a part)
"41" => {
if s(r, "ISHIDDEN") == "T" { continue; }
let text = s(r, "TEXT");
if text.is_empty() || text.starts_with('=') { continue; }
let (x, y) = (cx(r, "LOCATION.X"), cy(r, "LOCATION.Y"));
ext.push((x, y));
if let Some(ci) = c { cext[ci].push((x, y)); cext[ci].push((x + text.chars().count() as f64 * 0.9, y - 1.6)); }
let _ = write!(body, r##"<text{} x="{:.3}" y="{:.3}" font-size="2.1" fill="{}">{}</text>"##, tag, x, y, C_PARAM, esc(text));
}
_ => {}
}
}
// component bboxes / halo regions from what we actually drew
for (ci, pts) in cext.iter().enumerate() {
if pts.is_empty() {
let (x, y) = (comps[ci].x, comps[ci].y);
comps[ci].bbox = [x - 2.0, y - 2.0, x + 2.0, y + 2.0];
} else {
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (x, y) in pts { mnx = mnx.min(*x); mxx = mxx.max(*x); mny = mny.min(*y); mxy = mxy.max(*y); }
comps[ci].bbox = [mnx, mny, mxx, mxy];
}
comps[ci].regions = vec![comps[ci].bbox];
}
// components without a designator can't be hovered meaningfully
comps.retain(|c| !c.reference.is_empty());
if ext.is_empty() { ext.push((0.0, 0.0)); ext.push((100.0, 100.0)); }
let m = 5.0;
let minx = ext.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - m;
let miny = ext.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - m;
let maxx = ext.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + m;
let maxy = ext.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + m;
let sheet = Sheet { comps, sub_sheets: Vec::new(), content_bbox: (minx, miny, maxx - minx, maxy - miny) };
let mut out = String::new();
let _ = write!(out, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet" font-family="Times New Roman, Times, serif">"##,
minx, miny, maxx - minx, maxy - miny);
let _ = write!(out, r##"<rect class="adom-sheet-bg" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#ffffff"/>"##,
minx, miny, maxx - minx, maxy - miny);
let _ = write!(out, r##"<g class="adom-sheet">{}</g>"##, body);
out.push_str(&crate::sch_parse::build_overlay(&sheet));
out.push_str("</svg>");
Ok((out, sheet))
}