app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
2.17.0: assemble verb (multi-deviceset .lbr assembler for Cloud Libraries)
3 files changed
+329−2
VERSION+1−1@@ -1 +1 @@-2.16.0+2.17.0
src/assemble.rsadded+309@@ -0,0 +1,309 @@+//! Multi-deviceset .lbr assembler (Cloud Libraries phase 2).+//!+//! `adom-lbr assemble -o lib.lbr <part.lbr...>` merges the <packages>,+//! <symbols>, and <devicesets> of many single-part libraries into one+//! document: packages and symbols dedupe by CONTENT (identical bytes modulo+//! the name attribute collapse to one entry, references retargeted), name+//! collisions across different content rename with a numeric suffix, and any+//! input that fails lint is refused up front. The assembled document must+//! itself pass lint or the command fails.++use std::collections::HashMap;+use std::fs;+use std::path::{Path, PathBuf};+use std::process;++use crate::{lint_lbr, LintResult};++/// One named entry (a <package>, <symbol>, or <deviceset> block).+struct Entry {+ name: String,+ xml: String,+}++/// Inner text of `<tag>...</tag>` (first opening, last closing).+fn section_inner<'a>(content: &'a str, tag: &str) -> Option<&'a str> {+ let open = format!("<{}>", tag);+ let close = format!("</{}>", tag);+ let start = content.find(&open)? + open.len();+ let end = content.rfind(&close)?;+ if end < start {+ return None;+ }+ Some(&content[start..end])+}++/// Split a section into its top-level named entries. The EAGLE library+/// sections never nest an entry inside another entry of the same tag, so+/// scanning open/close pairs is sound.+fn split_entries(section: &str, tag: &str) -> Vec<Entry> {+ let mut out = Vec::new();+ let open_a = format!("<{} ", tag);+ let open_b = format!("<{}>", tag);+ let close = format!("</{}>", tag);+ let mut pos = 0;+ while pos < section.len() {+ let a = section[pos..].find(&open_a).map(|i| i + pos);+ let b = section[pos..].find(&open_b).map(|i| i + pos);+ let start = match (a, b) {+ (Some(x), Some(y)) => x.min(y),+ (Some(x), None) => x,+ (None, Some(y)) => y,+ (None, None) => break,+ };+ let end = match section[start..].find(&close) {+ Some(i) => start + i + close.len(),+ None => break,+ };+ let xml = section[start..end].to_string();+ let name = attr_value(&xml, "name").unwrap_or_default();+ out.push(Entry { name, xml });+ pos = end;+ }+ out+}++/// First `attr="value"` in the first tag of a block.+fn attr_value(xml: &str, attr: &str) -> Option<String> {+ let tag_end = xml.find('>')?;+ let head = &xml[..tag_end];+ let needle = format!("{}=\"", attr);+ let i = head.find(&needle)? + needle.len();+ let j = head[i..].find('"')? + i;+ Some(head[i..j].to_string())+}++/// Content key for dedup: the block with its name attribute and all+/// whitespace runs normalized, so formatting differences do not defeat it.+fn content_key(xml: &str, name: &str) -> String {+ let named = format!("name=\"{}\"", name);+ let stripped = xml.replacen(&named, "name=\"\"", 1);+ stripped.split_whitespace().collect::<Vec<_>>().join(" ")+}++/// Replace `attr="old"` with `attr="new"` everywhere in a block.+fn rewrite_attr(xml: &str, attr: &str, old: &str, new: &str) -> String {+ xml.replace(+ &format!("{}=\"{}\"", attr, old),+ &format!("{}=\"{}\"", attr, new),+ )+}++/// Self-closing <layer .../> lines from a document, keyed by layer number.+fn collect_layers(content: &str, seen: &mut HashMap<String, ()>, out: &mut Vec<String>) {+ let mut pos = 0;+ while let Some(i) = content[pos..].find("<layer ") {+ let start = pos + i;+ let end = match content[start..].find("/>") {+ Some(j) => start + j + 2,+ None => break,+ };+ let xml = &content[start..end];+ if let Some(num) = attr_value(xml, "number") {+ if seen.insert(num, ()).is_none() {+ out.push(xml.to_string());+ }+ }+ pos = end;+ }+}++struct Pool {+ entries: Vec<Entry>,+ by_key: HashMap<String, String>, // content key -> canonical name+ names: HashMap<String, ()>, // taken names+}++impl Pool {+ fn new() -> Self {+ Pool { entries: Vec::new(), by_key: HashMap::new(), names: HashMap::new() }+ }+ /// Add an entry, deduping by content and resolving name collisions.+ /// Returns the name the entry ended up under, and whether it was deduped.+ fn add(&mut self, e: Entry, dedup_by_content: bool) -> (String, bool) {+ let key = content_key(&e.xml, &e.name);+ if dedup_by_content {+ if let Some(existing) = self.by_key.get(&key) {+ return (existing.clone(), true);+ }+ }+ let mut name = e.name.clone();+ let mut n = 2;+ while self.names.contains_key(&name) {+ name = format!("{}_{}", e.name, n);+ n += 1;+ }+ let xml = if name != e.name {+ e.xml.replacen(+ &format!("name=\"{}\"", e.name),+ &format!("name=\"{}\"", name),+ 1,+ )+ } else {+ e.xml+ };+ self.names.insert(name.clone(), ());+ self.by_key.insert(key, name.clone());+ self.entries.push(Entry { name: name.clone(), xml });+ (name, false)+ }+}++pub fn run(out: &Path, name: Option<&str>, inputs: &[PathBuf]) {+ if inputs.is_empty() {+ eprintln!("ERROR: No input .lbr files given");+ eprintln!("Hint: adom-lbr assemble -o lib.lbr part1.lbr part2.lbr ...");+ process::exit(1);+ }++ let mut packages = Pool::new();+ let mut symbols = Pool::new();+ let mut devicesets = Pool::new();+ let mut layer_seen: HashMap<String, ()> = HashMap::new();+ let mut layers: Vec<String> = Vec::new();+ let mut renamed: Vec<serde_json::Value> = Vec::new();+ let mut packages_deduped = 0usize;+ let mut symbols_deduped = 0usize;++ for input in inputs {+ let content = fs::read_to_string(input).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", input.display(), e);+ process::exit(1);+ });+ // Refuse lint-failing inputs: the assembled library only ships clean.+ let lint = lint_lbr(&content);+ if !lint.passed {+ eprintln!(+ "ERROR: {} fails lint ({}); refusing to assemble it",+ input.display(),+ lint.errors+ .first()+ .map(|e| e.code.clone())+ .unwrap_or_else(|| "error".into())+ );+ for err in &lint.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ process::exit(2);+ }++ collect_layers(&content, &mut layer_seen, &mut layers);++ let pkg_entries = section_inner(&content, "packages")+ .map(|s| split_entries(s, "package"))+ .unwrap_or_default();+ let sym_entries = section_inner(&content, "symbols")+ .map(|s| split_entries(s, "symbol"))+ .unwrap_or_default();+ let ds_entries = section_inner(&content, "devicesets")+ .map(|s| split_entries(s, "deviceset"))+ .unwrap_or_default();++ // Per-input rename maps, applied to this input's devicesets only+ // (each part file is self-contained).+ let mut pkg_map: HashMap<String, String> = HashMap::new();+ let mut sym_map: HashMap<String, String> = HashMap::new();++ for e in pkg_entries {+ let old = e.name.clone();+ let (new_name, deduped) = packages.add(e, true);+ if deduped {+ packages_deduped += 1;+ }+ if new_name != old {+ if !deduped {+ renamed.push(serde_json::json!({"kind": "package", "from": old, "to": new_name, "file": input.display().to_string()}));+ }+ pkg_map.insert(old, new_name);+ }+ }+ for e in sym_entries {+ let old = e.name.clone();+ let (new_name, deduped) = symbols.add(e, true);+ if deduped {+ symbols_deduped += 1;+ }+ if new_name != old {+ if !deduped {+ renamed.push(serde_json::json!({"kind": "symbol", "from": old, "to": new_name, "file": input.display().to_string()}));+ }+ sym_map.insert(old, new_name);+ }+ }+ for e in ds_entries {+ let mut xml = e.xml;+ for (old, new) in &sym_map {+ xml = rewrite_attr(&xml, "symbol", old, new);+ }+ for (old, new) in &pkg_map {+ xml = rewrite_attr(&xml, "package", old, new);+ }+ let old = e.name.clone();+ let (new_name, _) = devicesets.add(Entry { name: e.name, xml }, false);+ if new_name != old {+ renamed.push(serde_json::json!({"kind": "deviceset", "from": old, "to": new_name, "file": input.display().to_string()}));+ }+ }+ }++ let description = name.unwrap_or("Adom Cloud Library");+ let mut xml = String::with_capacity(64 * 1024);+ xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");+ xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");+ xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");+ xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");+ xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");+ xml.push_str("<layers>\n");+ for l in &layers {+ xml.push_str(l);+ xml.push('\n');+ }+ xml.push_str("</layers>\n");+ xml.push_str("<library>\n");+ xml.push_str(&format!("<description>{}</description>\n", crate::esc_xml(description)));+ xml.push_str("<packages>\n");+ for e in &packages.entries {+ xml.push_str(&e.xml);+ xml.push('\n');+ }+ xml.push_str("</packages>\n<symbols>\n");+ for e in &symbols.entries {+ xml.push_str(&e.xml);+ xml.push('\n');+ }+ xml.push_str("</symbols>\n<devicesets>\n");+ for e in &devicesets.entries {+ xml.push_str(&e.xml);+ xml.push('\n');+ }+ xml.push_str("</devicesets>\n</library>\n</drawing>\n</eagle>\n");++ // The assembled document is held to the same gate as its inputs.+ let lint: LintResult = lint_lbr(&xml);+ if !lint.passed {+ eprintln!("ERROR: assembled library fails lint");+ for err in &lint.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ process::exit(2);+ }++ if let Err(e) = fs::write(out, &xml) {+ eprintln!("ERROR: Cannot write {}: {}", out.display(), e);+ process::exit(1);+ }++ let summary = serde_json::json!({+ "passed": true,+ "out": out.display().to_string(),+ "parts": inputs.len(),+ "packages": packages.entries.len(),+ "symbols": symbols.entries.len(),+ "devicesets": devicesets.entries.len(),+ "packages_deduped": packages_deduped,+ "symbols_deduped": symbols_deduped,+ "renamed": renamed,+ "lint_warnings": lint.warnings.len(),+ });+ println!("{}", serde_json::to_string_pretty(&summary).unwrap());+}
src/main.rs+19−1@@ -1,3 +1,4 @@+mod assemble; mod deliver; mod server; mod manage;@@ -95,6 +96,20 @@ enum Commands { /// Path to the .lbr file path: PathBuf, },+ /// Assemble many single-part .lbr files into ONE multi-deviceset library.+ /// Packages and symbols dedupe by content, name collisions rename with a+ /// numeric suffix, lint-failing inputs are refused, and the assembled+ /// document must itself pass lint. Prints a JSON summary.+ Assemble {+ /// Output .lbr path+ #[arg(long, short = 'o')]+ out: PathBuf,+ /// Library description (default: Adom Cloud Library)+ #[arg(long)]+ name: Option<String>,+ /// Input part .lbr files+ inputs: Vec<PathBuf>,+ }, /// Validate a .lbr file has complete deviceset (symbol + package + connects) Validate { /// Path to the .lbr file@@ -603,7 +618,7 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String { // ── EAGLE .lbr generator ── -fn esc_xml(s: &str) -> String {+pub(crate) fn esc_xml(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">")@@ -1917,6 +1932,9 @@ fn main() { } } + Commands::Assemble { out, name, inputs } => {+ assemble::run(&out, name.as_deref(), &inputs);+ } Commands::Validate { path } => { let content = fs::read_to_string(&path).unwrap_or_else(|e| { eprintln!("ERROR: Cannot read {}: {}", path.display(), e);