master
Colby Knox Merge PR #358: kicad-lane: 1.0.91, cleanup_libs, build_symbol_modes, add_symbol_art (#29) e1fa2ce 11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
//! Symbol authoring on `.kicad_sym` libraries (KiCad 10 format): append a symbol built
//! from a pin list (rectangle body sized to the pins and names, fields, optional units),
//! delete one, and read one back with its pins, units and fields. Library edits go through
//! the lossless document model, so the other symbols in the file stay byte-identical.

use std::collections::BTreeMap;

use serde_json::{json, Value};

use crate::pcb::num;
use crate::schematic::{flatten_lib_symbol, parse_node, pretty, quote, Document, Node};

pub const SYM_VERSION: &str = "20251024";
pub const PIN_TYPES: &[&str] = &["input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect"];
pub const PIN_SHAPES: &[&str] = &["line", "inverted", "clock", "inverted_clock", "input_low", "clock_low", "output_low", "edge_clock_high", "non_logic"];
const GRID: f64 = 2.54;

pub fn empty_library() -> String {
    format!("(kicad_symbol_lib\n\t(version {SYM_VERSION})\n\t(generator \"adom_kicad_bridge\")\n\t(generator_version \"10.0\")\n)\n")
}

#[derive(Clone, Debug)]
struct Pin {
    number: String,
    name: String,
    kind: String,
    shape: String,
    side: String,
    order: f64,
    length: f64,
    unit: u32,
    hidden: bool,
    at: Option<(f64, f64, f64)>,
}

fn pin_from_json(v: &Value, i: usize) -> Result<Pin, String> {
    let number = match v.get("number") {
        Some(Value::String(s)) if !s.is_empty() => s.clone(),
        Some(Value::Number(n)) => n.to_string(),
        _ => return Err(format!("pin {}: number is required", i + 1)),
    };
    let name = v.get("name").and_then(Value::as_str).unwrap_or("~").to_string();
    let kind = v.get("type").and_then(Value::as_str).unwrap_or("passive").to_string();
    if !PIN_TYPES.contains(&kind.as_str()) {
        return Err(format!("pin {number}: type must be one of {PIN_TYPES:?}"));
    }
    let shape = v.get("shape").and_then(Value::as_str).unwrap_or("line").to_string();
    if !PIN_SHAPES.contains(&shape.as_str()) {
        return Err(format!("pin {number}: shape must be one of {PIN_SHAPES:?}"));
    }
    let side = v.get("side").and_then(Value::as_str).unwrap_or(match kind.as_str() {
        "output" | "power_out" | "tri_state" | "open_collector" | "open_emitter" => "right",
        _ => "left",
    }).to_string();
    if !["left", "right", "top", "bottom"].contains(&side.as_str()) {
        return Err(format!("pin {number}: side must be left, right, top or bottom"));
    }
    let at = match v.get("at") {
        Some(Value::Array(a)) if a.len() >= 2 => Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0), a.get(2).and_then(Value::as_f64).unwrap_or(0.0))),
        _ => None,
    };
    Ok(Pin {
        number,
        name,
        kind,
        shape,
        side,
        order: v.get("position").and_then(Value::as_f64).unwrap_or(i as f64),
        length: v.get("length").and_then(Value::as_f64).unwrap_or(GRID),
        unit: v.get("unit").and_then(Value::as_u64).unwrap_or(1).max(1) as u32,
        hidden: v.get("hidden").and_then(Value::as_bool).unwrap_or(false),
        at,
    })
}

fn up(v: f64) -> f64 {
    (v / GRID - 1e-9).ceil() * GRID
}

fn font() -> &'static str {
    "(effects (font (size 1.27 1.27)))"
}

fn prop(name: &str, value: &str, at: (f64, f64), hide: bool) -> String {
    format!("(property {} {} (at {} {} 0) (show_name no) (do_not_autoplace no){} {})", quote(name), quote(value), num(at.0), num(at.1), if hide { " (hide yes)" } else { "" }, font())
}

/// One unit's body and pins. Returns (rectangle and pin texts, body top y, body bottom y).
fn unit_block(pins: &[&Pin]) -> (Vec<String>, f64, f64) {
    let side = |s: &str| {
        let mut v: Vec<&&Pin> = pins.iter().filter(|p| p.side == s && p.at.is_none()).collect();
        v.sort_by(|a, b| a.order.partial_cmp(&b.order).unwrap());
        v
    };
    let (l, r, t, b) = (side("left"), side("right"), side("top"), side("bottom"));
    let chars = |v: &[&&Pin]| v.iter().map(|p| if p.name == "~" { 0 } else { p.name.chars().count() }).max().unwrap_or(0) as f64;
    // Names sit inside the body: ~1 mm per character at 1.27 mm text.
    let need_w = (chars(&l) + chars(&r)) * 1.0 + 2.54;
    let need_w = need_w.max((t.len().max(b.len()) as f64 + 1.0) * GRID).max(5.08);
    let half_w = up(need_w / 2.0);
    let rows = l.len().max(r.len()).max(1) as f64;
    let top_y = ((rows - 1.0) / 2.0).floor() * GRID;
    let need_h_chars = (chars(&t) + chars(&b)) * 1.0;
    let mut body_top = top_y + GRID;
    let mut body_bot = top_y - (rows - 1.0) * GRID - GRID;
    if body_top - body_bot < need_h_chars + 2.54 {
        let extra = up((need_h_chars + 2.54 - (body_top - body_bot)) / 2.0);
        body_top += extra;
        body_bot -= extra;
    }
    let mut out = vec![format!("(rectangle (start {} {}) (end {} {}) (stroke (width 0.254) (type default)) (fill (type background)))", num(-half_w), num(body_top), num(half_w), num(body_bot))];
    let pin_text = |p: &Pin, x: f64, y: f64, ang: f64| {
        format!(
            "(pin {} {} (at {} {} {}) (length {}){} (name {} {}) (number {} {}))",
            p.kind, p.shape, num(x), num(y), num(ang), num(p.length), if p.hidden { " (hide yes)" } else { "" }, quote(&p.name), font(), quote(&p.number), font()
        )
    };
    for (i, p) in l.iter().enumerate() {
        out.push(pin_text(p, -half_w - p.length, top_y - i as f64 * GRID, 0.0));
    }
    for (i, p) in r.iter().enumerate() {
        out.push(pin_text(p, half_w + p.length, top_y - i as f64 * GRID, 180.0));
    }
    let x0 = -(((t.len() as f64) - 1.0) / 2.0).floor() * GRID;
    for (i, p) in t.iter().enumerate() {
        out.push(pin_text(p, x0 + i as f64 * GRID, body_top + p.length, 270.0));
    }
    let x0 = -(((b.len() as f64) - 1.0) / 2.0).floor() * GRID;
    for (i, p) in b.iter().enumerate() {
        out.push(pin_text(p, x0 + i as f64 * GRID, body_bot - p.length, 90.0));
    }
    for p in pins.iter().filter(|p| p.at.is_some()) {
        let (x, y, a) = p.at.unwrap();
        out.push(pin_text(p, x, y, a));
    }
    (out, body_top, body_bot)
}

/// The `(symbol ...)` text for a new symbol from the verb's args.
pub fn build_symbol(name: &str, args: &Value) -> Result<String, String> {
    if name.trim().is_empty() || name.contains([':', '"', '/', '\\']) {
        return Err("name must be a plain symbol name (no : / \\ or quotes)".into());
    }
    let pins_v = args.get("pins").and_then(Value::as_array).ok_or("pins is required: a list of {number, name, type, side}")?;
    let pins: Vec<Pin> = pins_v.iter().enumerate().map(|(i, p)| pin_from_json(p, i)).collect::<Result<_, _>>()?;
    let mut seen: BTreeMap<(u32, &str), usize> = BTreeMap::new();
    for p in &pins {
        *seen.entry((p.unit, p.number.as_str())).or_default() += 1;
    }
    if let Some(((u, n), _)) = seen.iter().find(|(_, c)| **c > 1) {
        return Err(format!("pin number {n} appears twice in unit {u}"));
    }
    let units = pins.iter().map(|p| p.unit).max().unwrap_or(1);
    let reference = args.get("reference").and_then(Value::as_str).unwrap_or("U");
    let value = args.get("value").and_then(Value::as_str).unwrap_or(name);
    let mut bodies: Vec<String> = Vec::new();
    let (mut top, mut bot) = (0.0f64, 0.0f64);
    for u in 1..=units {
        let these: Vec<&Pin> = pins.iter().filter(|p| p.unit == u).collect();
        if these.is_empty() {
            return Err(format!("unit {u} has no pins (units must be numbered 1..{units} without gaps)"));
        }
        let (items, t, b) = unit_block(&these);
        top = top.max(t);
        bot = bot.min(b);
        if units == 1 {
            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_0_1")), items[0]));
            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_1_1")), items[1..].join(" ")));
        } else {
            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_{u}_1")), items.join(" ")));
        }
    }
    let mut parts: Vec<String> = Vec::new();
    if args.get("hidePinNumbers").and_then(Value::as_bool).unwrap_or(false) {
        parts.push("(pin_numbers (hide yes))".into());
    }
    parts.push(format!("(pin_names (offset {}))", num(args.get("pinNameOffset").and_then(Value::as_f64).unwrap_or(1.016))));
    parts.push("(exclude_from_sim no) (in_bom yes) (on_board yes) (in_pos_files yes) (duplicate_pin_numbers_are_jumpers no)".into());
    let half_hint = 0.0;
    parts.push(prop("Reference", reference, (half_hint, top + 1.27), false));
    parts.push(prop("Value", value, (half_hint, bot - 1.27), false));
    parts.push(prop("Footprint", args.get("footprint").and_then(Value::as_str).unwrap_or(""), (0.0, bot - 3.81), true));
    parts.push(prop("Datasheet", args.get("datasheet").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), true));
    parts.push(prop("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), true));
    if let Some(k) = args.get("keywords") {
        let k = match k {
            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
            Value::String(s) => s.clone(),
            _ => String::new(),
        };
        parts.push(prop("ki_keywords", &k, (0.0, 0.0), true));
    }
    if let Some(f) = args.get("fpFilters") {
        let f = match f {
            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
            Value::String(s) => s.clone(),
            _ => String::new(),
        };
        parts.push(prop("ki_fp_filters", &f, (0.0, 0.0), true));
    }
    if let Some(fields) = args.get("fields").and_then(Value::as_object) {
        for (k, v) in fields {
            if ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&k.as_str()) {
                continue;
            }
            parts.push(prop(k, v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()).as_str(), (0.0, 0.0), true));
        }
    }
    parts.extend(bodies);
    parts.push("(embedded_fonts no)".into());
    Ok(format!("(symbol {} {})", quote(name), parts.join(" ")))
}

fn top_symbols(doc: &Document) -> Vec<(usize, String)> {
    doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol")).filter_map(|(i, c)| c.arg(0).map(|n| (i, n))).collect()
}

/// Append (or with `overwrite`, replace) a symbol in library text. Returns the new text.
pub fn add_symbol(lib_text: &str, name: &str, args: &Value, overwrite: bool) -> Result<(String, bool), String> {
    let mut doc = Document::parse(lib_text)?;
    if doc.root.head() != Some("kicad_symbol_lib") {
        return Err("not a symbol library (top-level list is not kicad_symbol_lib)".into());
    }
    let block = build_symbol(name, args)?;
    let mut node = parse_node(&block)?;
    pretty(&mut node, 1);
    let existing = top_symbols(&doc).into_iter().find(|(_, n)| n == name).map(|(i, _)| i);
    let replaced = existing.is_some();
    let kids = doc.root.children_mut().unwrap();
    match existing {
        Some(_) if !overwrite => return Err(format!("symbol {name} already exists in the library; pass overwrite:true to replace it")),
        Some(i) => {
            node.lead = kids[i].lead.clone();
            kids[i] = node;
        }
        None => {
            let at = kids.iter().rposition(|c| c.head() == Some("symbol")).map(|i| i + 1).unwrap_or(kids.len());
            kids.insert(at, node);
        }
    }
    let out = doc.to_text();
    crate::lint::check_symbol_lib_structure(&out)?;
    if !crate::libraries::list_symbols_in_text(&out).iter().any(|n| n == name) {
        return Err("the new symbol does not read back".into());
    }
    Ok((out, replaced))
}

/// Remove a top-level symbol. Refuses when another symbol extends it unless `force`.
pub fn delete_symbol(lib_text: &str, name: &str, force: bool) -> Result<(String, Vec<String>), String> {
    let mut doc = Document::parse(lib_text)?;
    if doc.root.head() != Some("kicad_symbol_lib") {
        return Err("not a symbol library".into());
    }
    let syms = top_symbols(&doc);
    let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else {
        let names: Vec<&str> = syms.iter().map(|(_, n)| n.as_str()).take(40).collect();
        return Err(format!("no symbol {name} in the library (it has: {})", names.join(", ")));
    };
    let children: Vec<String> = doc.root.children().iter().filter(|c| c.head() == Some("symbol") && c.child_value("extends").as_deref() == Some(name)).filter_map(|c| c.arg(0)).collect();
    if !children.is_empty() && !force {
        return Err(format!("{} extend {name}: delete them first or pass force:true (they will no longer load)", children.join(", ")));
    }
    doc.root.children_mut().unwrap().remove(*idx);
    let out = doc.to_text();
    crate::lint::check_symbol_lib_structure(&out)?;
    Ok((out, children))
}

/// One graphic of a body, as KiCad writes it: {rectangle: {start, end}} | {polyline: [[x, y], ...]} | {circle: {center,
/// radius}} | {arc: {start, mid, end}}, mm, y up as the symbol editor shows it; `fill` none | background | outline.
fn graphic_text(g: &Value) -> Result<String, String> {
    let n = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).ok_or_else(|| format!("bad point in {g}"));
    let pt = |v: &Value| -> Result<String, String> { Ok(format!("{} {}", num(n(v, 0)?), num(n(v, 1)?))) };
    let fill = g.get("fill").and_then(Value::as_str).unwrap_or("none");
    let width = g.get("width").and_then(Value::as_f64).unwrap_or(0.254);
    let tail = format!("(stroke (width {}) (type default)) (fill (type {fill}))", num(width));
    if let Some(r) = g.get("rectangle") {
        return Ok(format!("(rectangle (start {}) (end {}) {tail})", pt(&r["start"])?, pt(&r["end"])?));
    }
    if let Some(p) = g.get("polyline").and_then(Value::as_array) {
        let pts: Vec<String> = p.iter().map(|q| pt(q).map(|s| format!("(xy {s})"))).collect::<Result<_, _>>()?;
        if pts.len() < 2 {
            return Err("a polyline needs two points or more".into());
        }
        return Ok(format!("(polyline (pts {}) {tail})", pts.join(" ")));
    }
    if let Some(c) = g.get("circle") {
        return Ok(format!("(circle (center {}) (radius {}) {tail})", pt(&c["center"])?, num(c["radius"].as_f64().ok_or("a circle needs radius")?)));
    }
    if let Some(a) = g.get("arc") {
        return Ok(format!("(arc (start {}) (mid {}) (end {}) {tail})", pt(&a["start"])?, pt(&a["mid"])?, pt(&a["end"])?));
    }
    Err(format!("a graphic is rectangle, polyline, circle or arc: {g}"))
}

/// The standard's build_symbol_modes in KiCad's terms: give `name` its De Morgan alternate body (body style 2). Each
/// unit gets `graphics` (the alternate's drawing) and a copy of its standard body's pins, with `pin_shapes` ({number:
/// "inverted" | "line" | ...}) changing a pin's shape in the alternate; the symbol is marked `(body_styles demorgan)`.
/// An existing alternate body is replaced only with `replace`. Returns the new text and the units written.
pub fn build_alternate_body(lib_text: &str, name: &str, graphics: &[Value], pin_shapes: &serde_json::Map<String, Value>, replace: bool) -> Result<(String, Vec<u32>), String> {
    let gfx: Vec<String> = graphics.iter().map(graphic_text).collect::<Result<_, _>>()?;
    if gfx.is_empty() {
        return Err("graphics is required: the alternate body's drawing".into());
    }
    let mut doc = Document::parse(lib_text)?;
    let syms = top_symbols(&doc);
    let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else {
        return Err(format!("no symbol {name} in the library"));
    };
    let sym = &mut doc.root.children_mut().unwrap()[*idx];
    if sym.child_value("extends").is_some() {
        return Err(format!("{name} extends another symbol: give the alternate body to that one"));
    }
    let base = name.to_string();
    let subs: Vec<(u32, u32)> = sym.children().iter().filter(|c| c.head() == Some("symbol")).filter_map(|c| {
        let n = c.arg(0)?;
        let rest = n.strip_prefix(&format!("{base}_"))?;
        let (u, st) = rest.rsplit_once('_')?;
        Some((u.parse().ok()?, st.parse().ok()?))
    }).collect();
    if subs.iter().any(|(_, st)| *st == 2) {
        if !replace {
            return Err(format!("{name} already has an alternate body: pass replace:true to rewrite it"));
        }
        if let Some(c) = sym.children_mut() {
            c.retain(|n| !(n.head() == Some("symbol") && n.arg(0).map(|a| a.ends_with("_2") && a.starts_with(&format!("{base}_"))).unwrap_or(false)));
        }
    }
    let mut units: Vec<u32> = subs.iter().filter(|(u, st)| *st == 1 && *u >= 1).map(|(u, _)| *u).collect();
    units.sort();
    units.dedup();
    if units.is_empty() {
        units.push(1);
    }
    let mut new_subs = Vec::new();
    for u in &units {
        let mut pins = Vec::new();
        for src in [format!("{base}_{u}_1"), format!("{base}_0_1")] {
            if let Some(n) = sym.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(src.as_str())) {
                for p in n.children().iter().filter(|c| c.head() == Some("pin")) {
                    let mut p = p.clone();
                    if let Some(shape) = p.child_value("number").and_then(|num| pin_shapes.get(&num)).and_then(Value::as_str) {
                        p.set_arg(1, Node::atom(shape));
                    }
                    pins.push(p.to_text());
                }
            }
        }
        let text = format!("(symbol \"{base}_{u}_2\" {} {})", gfx.join(" "), pins.join(" "));
        let mut node = parse_node(&text)?;
        pretty(&mut node, 2);
        new_subs.push(node);
    }
    let kids = sym.children_mut().unwrap();
    if !kids.iter().any(|c| c.head() == Some("body_styles")) {
        let mut b = parse_node("(body_styles demorgan)")?;
        pretty(&mut b, 2);
        let at = kids.iter().position(|c| c.head() == Some("property")).unwrap_or(kids.len());
        kids.insert(at, b);
    }
    let at = kids.iter().rposition(|c| c.head() == Some("symbol")).map(|i| i + 1).unwrap_or(kids.len());
    for (k, n) in new_subs.into_iter().enumerate() {
        kids.insert(at + k, n);
    }
    let out = doc.to_text();
    crate::lint::check_symbol_lib_structure(&out)?;
    Ok((out, units))
}

/// The polylines a symbol draws (each a list of points, mm), from all its units: the art another symbol carries.
pub fn symbol_polylines(lib_text: &str, name: &str) -> Result<Vec<Vec<(f64, f64)>>, String> {
    let flat = flatten_lib_symbol(lib_text, name)?;
    let mut out = Vec::new();
    for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {
        for pl in sub.children().iter().filter(|c| c.head() == Some("polyline")) {
            let pts: Vec<(f64, f64)> = pl.child("pts").map(|p| p.children().iter().filter(|q| q.head() == Some("xy")).filter_map(|q| Some((q.num(0)?, q.num(1)?))).collect()).unwrap_or_default();
            if pts.len() >= 2 {
                out.push(pts);
            }
        }
    }
    Ok(out)
}

/// The standard's add_symbol_art: draw `art` (polylines, any units) inside the symbol's body rectangle, scaled to fit
/// with `margin` mm all round and centred, keeping its aspect. With `clear`, the unit's existing polylines, arcs and
/// circles go first (the body rectangle and the pins stay). Returns the new text and {bodyRectMm, polylines, points}.
pub fn add_symbol_art(lib_text: &str, name: &str, art: &[Vec<(f64, f64)>], clear: bool, margin: f64) -> Result<(String, Value), String> {
    let lines: Vec<&Vec<(f64, f64)>> = art.iter().filter(|l| l.len() >= 2).collect();
    if lines.is_empty() {
        return Err("art is required: polylines of two points or more".into());
    }
    let mut doc = Document::parse(lib_text)?;
    let syms = top_symbols(&doc);
    let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else { return Err(format!("no symbol {name} in the library")) };
    let sym = &mut doc.root.children_mut().unwrap()[*idx];
    // the body: the largest rectangle in any standard-body unit
    let mut best: Option<(usize, (f64, f64, f64, f64))> = None;
    for (k, sub) in sym.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol") && c.arg(0).map(|a| a.ends_with("_1")).unwrap_or(false)) {
        for r in sub.children().iter().filter(|c| c.head() == Some("rectangle")) {
            let (Some(a), Some(b)) = (r.child("start"), r.child("end")) else { continue };
            let (x0, y0, x1, y1) = (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0), b.num(0).unwrap_or(0.0), b.num(1).unwrap_or(0.0));
            let rect = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
            let area = (rect.2 - rect.0) * (rect.3 - rect.1);
            if best.map(|(_, b)| area > (b.2 - b.0) * (b.3 - b.1)).unwrap_or(true) {
                best = Some((k, rect));
            }
        }
    }
    let Some((unit_idx, body)) = best else { return Err(format!("{name} has no body rectangle to draw in")) };
    let (ax0, ay0, ax1, ay1) = lines.iter().flat_map(|l| l.iter()).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
    let (aw, ah) = ((ax1 - ax0).max(1e-9), (ay1 - ay0).max(1e-9));
    let (bw, bh) = ((body.2 - body.0 - 2.0 * margin).max(0.1), (body.3 - body.1 - 2.0 * margin).max(0.1));
    let k = (bw / aw).min(bh / ah);
    let (cx, cy) = ((body.0 + body.2) / 2.0, (body.1 + body.3) / 2.0);
    let (acx, acy) = ((ax0 + ax1) / 2.0, (ay0 + ay1) / 2.0);
    let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;
    let mut nodes = Vec::new();
    let mut points = 0;
    for l in &lines {
        let pts: Vec<String> = l.iter().map(|(x, y)| format!("(xy {} {})", num(r4(cx + (x - acx) * k)), num(r4(cy + (y - acy) * k)))).collect();
        points += pts.len();
        let mut n = parse_node(&format!("(polyline (pts {}) (stroke (width 0.1524) (type default)) (fill (type none)))", pts.join(" ")))?;
        pretty(&mut n, 3);
        nodes.push(n);
    }
    let unit = &mut sym.children_mut().unwrap()[unit_idx];
    let kids = unit.children_mut().unwrap();
    if clear {
        kids.retain(|c| !matches!(c.head(), Some("polyline") | Some("arc") | Some("circle") | Some("bezier")));
    }
    let at = kids.iter().position(|c| c.head() == Some("pin")).unwrap_or(kids.len());
    let count = nodes.len();
    for (i, n) in nodes.into_iter().enumerate() {
        kids.insert(at + i, n);
    }
    let out = doc.to_text();
    crate::lint::check_symbol_lib_structure(&out)?;
    Ok((out, json!({"bodyRectMm": [r4(body.0), r4(body.1), r4(body.2), r4(body.3)], "polylines": count, "points": points, "scale": r4(k)})))
}

/// Pins, units, fields and flags of one symbol (derived symbols resolved through `extends`).
pub fn symbol_info(lib_text: &str, name: &str) -> Result<Value, String> {
    let doc = Document::parse(lib_text)?;
    let raw = doc.root.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(name)).ok_or_else(|| {
        let names: Vec<String> = top_symbols(&doc).into_iter().map(|(_, n)| n).take(40).collect();
        format!("no symbol {name} in the library (it has: {})", names.join(", "))
    })?;
    let extends = raw.child_value("extends");
    let flat = flatten_lib_symbol(lib_text, name)?;
    let mut fields = serde_json::Map::new();
    for p in flat.children().iter().filter(|c| c.head() == Some("property")) {
        if let (Some(k), Some(v)) = (p.arg(0), p.arg(1)) {
            fields.insert(k, json!(v));
        }
    }
    let mut pins = Vec::new();
    let mut units: std::collections::BTreeSet<u32> = Default::default();
    let mut styles: std::collections::BTreeSet<u32> = Default::default();
    for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {
        let sname = sub.arg(0).unwrap_or_default();
        let mut it = sname.rsplitn(3, '_');
        let style: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(1);
        let unit: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
        if unit > 0 {
            units.insert(unit);
        }
        styles.insert(style);
        for p in sub.children().iter().filter(|c| c.head() == Some("pin")) {
            let at = p.child("at");
            pins.push(json!({
                "number": p.child("number").and_then(|n| n.arg(0)),
                "name": p.child("name").and_then(|n| n.arg(0)),
                "type": p.arg(0), "shape": p.arg(1),
                "at": at.map(|a| json!([a.num(0), a.num(1)])), "rotation": at.and_then(|a| a.num(2)),
                "length": p.child("length").and_then(|l| l.num(0)),
                "unit": unit, "bodyStyle": style,
                "hidden": p.child("hide").map(|h| h.arg(0).as_deref() != Some("no")).unwrap_or(false) || p.children().iter().any(|c| c.raw() == Some("hide")),
            }));
        }
    }
    let flag = |k: &str| flat.child_value(k).map(|v| v == "yes");
    Ok(json!({
        "name": name,
        "extends": extends,
        "power": flat.child("power").is_some(),
        "fields": fields,
        "unitCount": units.len().max(1),
        "bodyStyles": styles.len().max(1),
        "pinCount": pins.len(),
        "pins": pins,
        "inBom": flag("in_bom"), "onBoard": flag("on_board"), "excludeFromSim": flag("exclude_from_sim"),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn regulator() -> Value {
        json!({
            "reference": "U", "value": "LM1117", "footprint": "Package_TO_SOT_SMD:SOT-223-3_TabPin2", "description": "LDO 800 mA",
            "keywords": ["ldo", "regulator"], "fields": {"MPN": "LM1117IMP-3.3", "LCSC": "C6186"},
            "pins": [{"number": 3, "name": "VI", "type": "power_in", "side": "left"}, {"number": 2, "name": "VO", "type": "power_out"}, {"number": 1, "name": "GND", "type": "power_in", "side": "bottom"}]
        })
    }

    #[test]
    fn add_to_an_empty_library_and_read_back() {
        let (lib, replaced) = add_symbol(&empty_library(), "LM1117", &regulator(), false).unwrap();
        assert!(!replaced);
        crate::lint::check_symbol_lib_structure(&lib).unwrap();
        let inv = crate::lint::parse_kicad_sym(&lib);
        assert_eq!(inv["symbolCount"], 1);
        assert_eq!(inv["symbols"][0]["pinCount"], 3);
        assert_eq!(inv["fileVersion"], 20251024);
        let i = symbol_info(&lib, "LM1117").unwrap();
        assert_eq!(i["fields"]["MPN"], "LM1117IMP-3.3");
        assert_eq!(i["fields"]["ki_keywords"], "ldo regulator");
        assert_eq!(i["pinCount"], 3);
        let vo = i["pins"].as_array().unwrap().iter().find(|p| p["number"] == "2").unwrap();
        assert_eq!(vo["type"], "power_out");
        assert_eq!(vo["rotation"], 180.0, "a right-side pin points left into the body");
        let gnd = i["pins"].as_array().unwrap().iter().find(|p| p["number"] == "1").unwrap();
        assert_eq!(gnd["rotation"], 90.0);
        // every pin end sits on the 2.54 mm grid
        for p in i["pins"].as_array().unwrap() {
            for c in p["at"].as_array().unwrap() {
                let v = c.as_f64().unwrap() / 2.54;
                assert!((v - v.round()).abs() < 1e-6, "{p}");
            }
        }
        // a schematic can place it: the bridge's flattener reads it
        crate::schematic::flatten_lib_symbol(&lib, "LM1117").unwrap();
        assert!(add_symbol(&lib, "LM1117", &regulator(), false).unwrap_err().contains("overwrite"));
        let (lib2, replaced) = add_symbol(&lib, "LM1117", &regulator(), true).unwrap();
        assert!(replaced);
        assert_eq!(crate::libraries::list_symbols_in_text(&lib2), vec!["LM1117"]);
    }

    #[test]
    fn multi_unit_and_validation() {
        let args = json!({"reference": "U", "pins": [
            {"number": 1, "name": "OUTA", "type": "output", "unit": 1}, {"number": 2, "name": "-INA", "type": "input", "unit": 1}, {"number": 3, "name": "+INA", "type": "input", "unit": 1},
            {"number": 7, "name": "OUTB", "type": "output", "unit": 2}, {"number": 6, "name": "-INB", "type": "input", "unit": 2}, {"number": 5, "name": "+INB", "type": "input", "unit": 2},
            {"number": 8, "name": "V+", "type": "power_in", "unit": 3, "side": "top"}, {"number": 4, "name": "V-", "type": "power_in", "unit": 3, "side": "bottom"}]});
        let (lib, _) = add_symbol(&empty_library(), "LM358", &args, false).unwrap();
        let i = symbol_info(&lib, "LM358").unwrap();
        assert_eq!(i["unitCount"], 3);
        assert_eq!(i["pinCount"], 8);
        assert!(build_symbol("X", &json!({"pins": [{"number": 1}, {"number": 1}]})).unwrap_err().contains("twice"));
        assert!(build_symbol("X", &json!({"pins": [{"number": 1, "type": "weird"}]})).is_err());
        assert!(build_symbol("X", &json!({"pins": [{"number": 1, "unit": 2}]})).unwrap_err().contains("unit 1 has no pins"));
        assert!(build_symbol("A:B", &json!({"pins": [{"number": 1}]})).is_err());
    }

    #[test]
    fn delete_keeps_the_other_symbols_byte_identical_and_guards_extends() {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pcm/src/adom-sample-lib/symbols/Adom_Sample.kicad_sym");
        let text = std::fs::read_to_string(path).unwrap();
        let (out, _) = delete_symbol(&text, "AdomSample_C", false).unwrap();
        let names = crate::libraries::list_symbols_in_text(&out);
        assert_eq!(names.len(), 9);
        assert!(!names.contains(&"AdomSample_C".to_string()));
        let r_block = crate::libraries::extract_top_level_symbols(&text).unwrap().into_iter().find(|(n, _)| n == "AdomSample_R").unwrap().1;
        assert!(out.contains(&r_block), "untouched symbols stay byte-identical");
        assert!(delete_symbol(&text, "Nope", false).unwrap_err().contains("AdomSample_R"));
        let derived = text.replacen("(kicad_symbol_lib", "(kicad_symbol_lib\n\t(symbol \"R_alias\" (extends \"AdomSample_R\") (property \"Value\" \"R2\" (at 0 0 0)))", 1);
        assert!(delete_symbol(&derived, "AdomSample_R", false).unwrap_err().contains("R_alias"));
        let (_, orphans) = delete_symbol(&derived, "AdomSample_R", true).unwrap();
        assert_eq!(orphans, vec!["R_alias"]);
        let i = symbol_info(&derived, "R_alias").unwrap();
        assert_eq!(i["extends"], "AdomSample_R");
        assert_eq!(i["fields"]["Value"], "R2");
        assert_eq!(i["pinCount"], 2, "pins resolved through extends");
    }

    #[test]
    fn an_alternate_body_copies_the_pins_and_marks_the_symbol_demorgan() {
        let args = json!({"reference": "U", "pins": [{"number": 1, "name": "A", "type": "input"}, {"number": 2, "name": "Y", "type": "output", "side": "right"}]});
        let (lib, _) = add_symbol(&empty_library(), "BUF", &args, false).unwrap();
        let g = vec![json!({"polyline": [[-2.54, 2.54], [2.54, 0], [-2.54, -2.54], [-2.54, 2.54]], "fill": "background"})];
        let shapes: serde_json::Map<String, Value> = [("1".to_string(), json!("inverted"))].into_iter().collect();
        let (t, units) = build_alternate_body(&lib, "BUF", &g, &shapes, false).unwrap();
        assert_eq!(units, vec![1]);
        assert!(t.contains("(body_styles demorgan)") && t.contains("(symbol \"BUF_1_2\""), "{t}");
        let i = symbol_info(&t, "BUF").unwrap();
        let alt: Vec<&Value> = i["pins"].as_array().unwrap().iter().filter(|p| p["bodyStyle"] == 2).collect();
        assert_eq!(alt.len(), 2);
        assert_eq!(alt.iter().find(|p| p["number"] == "1").unwrap()["shape"], "inverted");
        assert!(build_alternate_body(&t, "BUF", &g, &shapes, false).is_err(), "an existing alternate needs replace");
        assert!(build_alternate_body(&t, "BUF", &g, &shapes, true).is_ok());
        assert!(build_alternate_body(&lib, "BUF", &[json!({"blob": 1})], &shapes, false).is_err());
    }

    #[test]
    fn art_is_fitted_inside_the_body_rectangle() {
        let args = json!({"reference": "U", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "side": "right"}]});
        let (lib, _) = add_symbol(&empty_library(), "CHIP", &args, false).unwrap();
        // a 100 x 50 outline in its own units
        let art = vec![vec![(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0), (0.0, 0.0)], vec![(10.0, 25.0), (90.0, 25.0)]];
        let (t, rep) = add_symbol_art(&lib, "CHIP", &art, true, 0.5).unwrap();
        assert_eq!(rep["polylines"], 2);
        let body = rep["bodyRectMm"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
        let drawn = symbol_polylines(&t, "CHIP").unwrap();
        for (x, y) in drawn.iter().flatten() {
            assert!(*x >= body[0] + 0.49 && *x <= body[2] - 0.49 && *y >= body[1] + 0.49 && *y <= body[3] - 0.49, "({x}, {y}) outside {body:?}");
        }
        assert_eq!(symbol_info(&t, "CHIP").unwrap()["pinCount"], 2, "pins stay");
        assert!(add_symbol_art(&lib, "CHIP", &[vec![(0.0, 0.0)]], true, 0.5).is_err());
    }
}