app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON — pins and pads, extracted from the PDF.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
use anyhow::{anyhow, Context as _, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Context {
pub mpn: String,
pub manufacturer: Option<String>,
pub package_hint: Option<String>,
pub description_hint: Option<String>,
pub family_hint: Option<String>,
pub datasheet_path: PathBuf,
pub datasheet_url: Option<String>,
pub datasheet_source_tier: Option<String>,
pub datasheet_source_host: Option<String>,
pub datasheet_sha256: String,
pub fetched_at: Option<String>,
pub extracted_at: String,
pub chip_dir: PathBuf,
pub out_dir: PathBuf,
pub page_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct InfoJson {
mpn: Option<String>,
manufacturer: Option<String>,
package: Option<String>,
description: Option<String>,
family: Option<String>,
datasheet_url: Option<String>,
datasheet_source: Option<String>,
/// Accept either a string ("2026-05-03") or a number (Unix timestamp
/// 1778065044). chip-fetcher writes both forms across vendors.
#[serde(default, deserialize_with = "de_string_or_number")]
fetched_at: Option<String>,
}
fn de_string_or_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(match v {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
}
impl Context {
pub fn ingest(
chip_dir: &Path,
pdf_override: Option<&Path>,
mpn_override: Option<&str>,
manuf_override: Option<&str>,
url_override: Option<&str>,
source_tier_override: Option<&str>,
out_dir_override: Option<&Path>,
) -> Result<Self> {
let chip_dir = chip_dir.canonicalize().with_context(|| {
format!("chip dir not found: {}", chip_dir.display())
})?;
let basename = chip_dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("chip dir has no basename"))?
.to_string();
let info_path = chip_dir.join("info.json");
let info: Option<InfoJson> = if info_path.exists() {
let raw = fs::read_to_string(&info_path).context("reading info.json")?;
Some(serde_json::from_str(&raw).context("parsing info.json")?)
} else {
None
};
let mpn = mpn_override
.map(|s| s.to_string())
.or_else(|| info.as_ref().and_then(|i| i.mpn.clone()))
.unwrap_or(basename.clone());
let datasheet_path = pdf_override
.map(|p| p.to_path_buf())
.unwrap_or_else(|| chip_dir.join(format!("{basename}.pdf")));
if !datasheet_path.exists() {
return Err(anyhow!(
"datasheet PDF not found at {} — pass --pdf to override",
datasheet_path.display()
));
}
let datasheet_sha256 = sha256_file(&datasheet_path)?;
let page_count = pdf_page_count(&datasheet_path);
let manufacturer = manuf_override
.map(|s| s.to_string())
.or_else(|| info.as_ref().and_then(|i| i.manufacturer.clone()));
let package_hint = info.as_ref().and_then(|i| i.package.clone());
let description_hint = info.as_ref().and_then(|i| i.description.clone());
let family_hint = info.as_ref().and_then(|i| i.family.clone());
let datasheet_url = url_override
.map(|s| s.to_string())
.or_else(|| info.as_ref().and_then(|i| i.datasheet_url.clone()));
let datasheet_source_host = info.as_ref().and_then(|i| i.datasheet_source.clone());
let datasheet_source_tier = source_tier_override
.map(|s| s.to_string())
.or_else(|| infer_source_tier(datasheet_source_host.as_deref()));
let fetched_at = info.as_ref().and_then(|i| i.fetched_at.clone());
let out_dir = out_dir_override
.map(|p| p.to_path_buf())
.unwrap_or_else(|| chip_dir.clone());
let extracted_at = now_iso8601();
Ok(Context {
mpn,
manufacturer,
package_hint,
description_hint,
family_hint,
datasheet_path,
datasheet_url,
datasheet_source_tier,
datasheet_source_host,
datasheet_sha256,
fetched_at,
extracted_at,
chip_dir,
out_dir,
page_count,
})
}
pub fn cache_dir(&self) -> PathBuf {
self.out_dir.join(".ds2sf-cache")
}
pub fn cache_dir_create(&self) -> Result<()> {
fs::create_dir_all(self.cache_dir()).context("creating cache dir")
}
pub fn write_pdf_sha_marker(&self) -> Result<()> {
let p = self.cache_dir().join("pdf-sha256.txt");
fs::write(&p, &self.datasheet_sha256).context("writing pdf-sha256.txt")
}
pub fn symbol_out_path(&self) -> PathBuf {
self.out_dir.join(format!("{}-symbol.extracted.json", self.mpn))
}
pub fn footprint_out_path(&self) -> PathBuf {
self.out_dir.join(format!("{}-footprint.extracted.json", self.mpn))
}
pub fn provenance_path(&self) -> PathBuf {
self.out_dir
.join(format!("{}-extraction.provenance.json", self.mpn))
}
pub fn footprint_svg_path(&self) -> PathBuf {
self.out_dir
.join(format!("{}-footprint.authoritative.svg", self.mpn))
}
pub fn symbol_cache_path(&self) -> PathBuf {
self.cache_dir().join("claude-symbol-response.json")
}
pub fn footprint_cache_path(&self) -> PathBuf {
self.cache_dir().join("claude-footprint-response.json")
}
pub fn outputs_exist(&self) -> Result<bool> {
Ok(self.symbol_out_path().exists()
&& self.footprint_out_path().exists()
&& self.provenance_path().exists())
}
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let mut h = Sha256::new();
h.update(&bytes);
Ok(hex(&h.finalize()))
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
fn pdf_page_count(path: &Path) -> Option<u32> {
// Use `pdfinfo` if available (poppler-utils). Fail-soft: None.
let out = std::process::Command::new("pdfinfo")
.arg(path)
.output()
.ok()?;
if !out.status.success() {
return None;
}
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(rest) = line.strip_prefix("Pages:") {
return rest.trim().parse().ok();
}
}
None
}
fn now_iso8601() -> String {
// Avoid pulling in chrono — the date-string format is informational.
// Use SystemTime with a tiny formatter.
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Convert to UTC components without chrono.
format_utc(secs)
}
fn format_utc(secs: i64) -> String {
// Days from 1970-01-01.
let days = secs.div_euclid(86_400);
let secs_of_day = secs.rem_euclid(86_400);
let h = secs_of_day / 3600;
let m = (secs_of_day % 3600) / 60;
let s = secs_of_day % 60;
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
// Public-domain algorithm from Howard Hinnant.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m as u32, d as u32)
}
fn infer_source_tier(host: Option<&str>) -> Option<String> {
let h = host?.to_lowercase();
let mfr_hosts = [
"ti.com", "ti.com/lit", "st.com", "nxp.com", "espressif.com",
"raspberrypi.com", "diodes.com", "renesas.com", "infineon.com",
"rohm.com", "onsemi.com", "microchip.com", "analog.com",
"vishay.com", "skyworksinc.com", "silabs.com",
];
if mfr_hosts.iter().any(|m| h.contains(m)) || h.contains("manufacturer_direct") {
return Some("mfr".to_string());
}
if h.contains("snapeda") || h.contains("snapmagic") {
return Some("snapmagic".to_string());
}
if h.contains("mouser") {
return Some("mouser".to_string());
}
if h.contains("digikey") {
return Some("digikey".to_string());
}
if h.contains("arrow") {
return Some("arrow".to_string());
}
if h.contains("componentsearchengine") || h.contains("ultralibrarian") {
return Some("cse".to_string());
}
if h.contains("lcsc") {
return Some("lcsc".to_string());
}
None
}