app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
//! SVG theming for symbols + footprints.
//!
//! Symbol theming ports the regex-replacement pass from
//! gallia/symbol-creator/lib/svg-theme.js applyDarkTheme() into Rust.
//! It transforms KiCad CLI's default-themed symbol SVG into the Adom
//! dark palette (canvas #0d1117, body gradient, white pin labels,
//! amber group labels). The `<style>` body is wrapped in CDATA so the
//! output is valid standalone XML — applyDarkTheme as written embeds a
//! literal `<g class="stroked-text">` inside a CSS comment, which trips
//! strict XML parsers when SVG is loaded via <object> or as a wiki
//! asset.
//!
//! Footprint theming keeps kicad-cli's native KiCad palette (the same
//! red/yellow/magenta the FpView app shows) and just paints them on
//! the canonical FpView dark canvas with a 20-unit PCB-style grid
//! (rgba(255,255,255,0.03) lines), mirroring
//! gallia/viewer/kicad-footprint-viewer.js generateFootprintViewer().
use regex::Regex;
const KICAD_BODY_STROKE: &str = "#840000";
const KICAD_BODY_FILL: &str = "#ffffc2";
const KICAD_TEXT_STROKE: &str = "#A90000";
const KICAD_HIDDEN_TEXT: &str = "#006464";
// Adom dark theme target colors.
const CANVAS_BG: &str = "#0d1117";
const BODY_STROKE: &str = "#30363d";
const BODY_FILL: &str = "#1a2332";
const BODY_FILL_GRAD: &str = "#141c27";
const PIN_WIRE: &str = "#4a5568";
const PIN_NAME: &str = "#ffffff";
const REF_TEXT: &str = "#484f58";
const PIN_DOT: &str = "#4a5568";
/// Apply the Adom dark theme to a KiCad-CLI-exported symbol SVG.
pub fn apply_dark_theme_to_symbol(raw: &str) -> String {
let mut s: String = raw
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n");
// Strip XML decl and DOCTYPE.
s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);
s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);
s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);
s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);
let s = s.trim();
// Replace KiCad's default white background rect (if present) — older
// KiCad outputs may include one, newer ones don't. We also unconditionally
// inject a viewBox-sized canvas <rect> below so the symbol always has
// an opaque Adom-bg behind it.
let bg_rect_re = Regex::new(r#"(<rect[^>]*fill=")(?i)white("[^>]*>)"#).unwrap();
let s = bg_rect_re.replace(s, format!("${{1}}{}${{2}}", CANVAS_BG)).to_string();
// Pull viewBox so we know how big to make the canvas bg rect.
let (vb_x, vb_y, vb_w, vb_h) =
match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)
.unwrap()
.captures(&s)
{
Some(c) => (
c[1].parse::<f64>().unwrap_or(0.0),
c[2].parse::<f64>().unwrap_or(0.0),
c[3].parse::<f64>().unwrap_or(100.0),
c[4].parse::<f64>().unwrap_or(100.0),
),
None => (0.0, 0.0, 100.0, 100.0),
};
// Canvas: solid Adom dark + the same atmosphere SymView's
// .canvas-wrap div adds via CSS — radial teal glow at center +
// 20-unit PCB grid. Baked into the SVG so the thumbnail looks
// identical to SymView when shown standalone (no HTML wrapper).
let cx = vb_x + vb_w / 2.0;
let cy = vb_y + vb_h / 2.0;
let glow_r = (vb_w.max(vb_h)) * 0.7;
let canvas_bg_rect = format!(
r##"<defs>
<radialGradient id="adom-canvas-glow" cx="{cx}" cy="{cy}" r="{r}" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#00b8b0" stop-opacity="0.06"/>
<stop offset="70%" stop-color="#00b8b0" stop-opacity="0"/>
</radialGradient>
<pattern id="adom-canvas-grid" x="0" y="0" width="2.54" height="2.54" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="2.54" y2="0" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>
<line x1="0" y1="0" x2="0" y2="2.54" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>
</pattern>
</defs>
<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{bg}"/>
<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-grid)"/>
<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-glow)"/>"##,
x = vb_x, y = vb_y, w = vb_w, h = vb_h,
cx = cx, cy = cy, r = glow_r, bg = CANVAS_BG,
);
// Inject a <defs> + <style> right after the opening <svg ...>.
// CDATA-wrap the style body so the literal `<g class=...>` inside the
// CSS comment doesn't trip strict XML parsers.
let defs_and_style = format!(
r#"
<defs>
<linearGradient id="adom-body-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="{body_fill}"/>
<stop offset="100%" stop-color="{body_fill_grad}"/>
</linearGradient>
</defs>
<style><![CDATA[
/* Stroked text groups (KiCad renders text as paths in <g class="stroked-text">) */
g.stroked-text path {{
stroke: {pin_name} !important;
fill: {pin_name} !important;
}}
text[opacity="0"] {{
fill: {pin_name} !important;
}}
.pin-highlight path {{
stroke: #00e6dc !important;
}}
.pin-highlight g.stroked-text path {{
stroke: #00e6dc !important;
}}
.group-highlight g.stroked-text path {{
stroke: #FFD180 !important;
}}
]]></style>"#,
body_fill = BODY_FILL,
body_fill_grad = BODY_FILL_GRAD,
pin_name = PIN_NAME,
);
let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();
let s = svg_open
.replace(s.as_ref(), format!("${{1}}\n{}\n{}", defs_and_style, canvas_bg_rect))
.to_string();
// Direct color replacements — CASE-INSENSITIVE on the hex digits
// because kicad-cli emits both #840000 and #FFFFC2 (uppercase) and
// the gallia source pattern uses /gi. Use Regex with `(?i)` for the
// hex tail; literal prefix stays case-sensitive.
let s = ci_replace_all(&s, &[
(format!("stroke:{}", KICAD_BODY_STROKE), format!("stroke:{}", PIN_WIRE)),
(format!("stroke=\"{}\"", KICAD_BODY_STROKE), format!("stroke=\"{}\"", PIN_WIRE)),
(format!("fill:{}", KICAD_BODY_STROKE), format!("fill:{}", PIN_DOT)),
(format!("fill=\"{}\"", KICAD_BODY_STROKE), format!("fill=\"{}\"", PIN_DOT)),
(format!("fill:{}", KICAD_BODY_FILL), "fill:url(#adom-body-grad)".to_string()),
(format!("fill=\"{}\"", KICAD_BODY_FILL), "fill=\"url(#adom-body-grad)\"".to_string()),
(format!("stroke:{}", KICAD_TEXT_STROKE), format!("stroke:{}", PIN_NAME)),
(format!("stroke=\"{}\"", KICAD_TEXT_STROKE), format!("stroke=\"{}\"", PIN_NAME)),
(format!("fill:{}", KICAD_TEXT_STROKE), format!("fill:{}", PIN_NAME)),
(format!("fill=\"{}\"", KICAD_TEXT_STROKE), format!("fill=\"{}\"", PIN_NAME)),
(format!("stroke:{}", KICAD_HIDDEN_TEXT), format!("stroke:{}", REF_TEXT)),
(format!("fill:{}", KICAD_HIDDEN_TEXT), format!("fill:{}", REF_TEXT)),
]);
// rgb(...) form for the body fill — kicad-cli sometimes uses rgb()
// notation for the same #FFFFC2 yellow.
let rgb_body = Regex::new(r#"fill="rgb\(\s*255\s*,\s*255\s*,\s*194\s*\)""#).unwrap();
let s = rgb_body.replace_all(&s, "fill=\"url(#adom-body-grad)\"").to_string();
// Strip the root <g> default fill:#000000 so symbol body shapes don't
// come out solid black on the dark canvas.
let root_g_black = Regex::new(r#"(<g\s+style="fill:#000000[^"]*")"#).unwrap();
let s = root_g_black
.replace(&s, |c: ®ex::Captures| {
c[1].replace("fill:#000000", "fill:none")
})
.to_string();
s
}
/// Re-theme a KiCad-CLI footprint SVG to match the FpView app:
/// - dark canvas #0d1117 + 20-unit PCB-style grid behind the geometry
/// - strip invisible REF**/value text + stroked-text groups (else they
/// inflate the viewBox)
/// - keep kicad-cli's native pad/silk/courtyard colors as-is, just
/// painted on the dark grid
pub fn theme_footprint_svg(raw: &str) -> String {
let mut s = raw.replace("\r\n", "\n");
s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);
s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);
s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);
s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);
s = strip_all_matches(&s, r#"<text[^>]*opacity="0"[^<]*</text>"#);
s = strip_all_dotall(&s, r#"<g class="stroked-text">.*?</g>"#);
let s = s.trim();
// Pull the SVG's viewBox so the bg rect covers the entire canvas.
let vb = match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)
.unwrap()
.captures(s)
{
Some(c) => (
c[1].parse::<f64>().unwrap_or(-50.0),
c[2].parse::<f64>().unwrap_or(-50.0),
c[3].parse::<f64>().unwrap_or(100.0),
c[4].parse::<f64>().unwrap_or(100.0),
),
None => (-50.0, -50.0, 100.0, 100.0),
};
let defs = r#"
<defs>
<pattern id="adom-pcb-grid" x="0" y="0" width="1" height="1" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="1" y2="0" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>
<line x1="0" y1="0" x2="0" y2="1" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>
</pattern>
</defs>"#;
let bg = format!(
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{CANVAS_BG}\"/>\n<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"url(#adom-pcb-grid)\"/>",
x = vb.0, y = vb.1, w = vb.2, h = vb.3,
);
let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();
let s = svg_open
.replace(s, format!("${{1}}\n{}\n{}", defs, bg))
.to_string();
// Strip any KiCad-CLI default white-ish page-background rect that
// would render in front of our dark canvas.
let white_bg_rect = Regex::new(
r#"<rect[^>]*fill="(?i)#?(?:f{2,6}|white)"[^>]*/?>"#,
).unwrap();
let s = white_bg_rect.replace_all(&s, "").to_string();
s
}
fn strip_first_match(s: &str, pat: &str) -> String {
Regex::new(pat).unwrap().replace(s, "").to_string()
}
fn strip_all_matches(s: &str, pat: &str) -> String {
Regex::new(pat).unwrap().replace_all(s, "").to_string()
}
fn strip_all_dotall(s: &str, pat: &str) -> String {
Regex::new(&format!(r"(?s){}", pat))
.unwrap()
.replace_all(s, "")
.to_string()
}
fn simple_replace_all(s: &str, pairs: &[(String, String)]) -> String {
let mut out = s.to_string();
for (from, to) in pairs {
out = out.replace(from, to);
}
out
}
/// Case-insensitive replace_all on each (from, to) pair. The literal
/// prefix (`fill:`, `stroke="`) stays case-sensitive; only the hex tail
/// after the `#` is matched insensitively. This matches the JS source
/// `/.../gi` semantics from gallia/symbol-creator/lib/svg-theme.js.
fn ci_replace_all(s: &str, pairs: &[(String, String)]) -> String {
let mut out = s.to_string();
for (from, to) in pairs {
// Split prefix-up-to-and-including-# vs. hex-tail at the first '#'
// so we can keep the prefix literal and apply (?i) to the hex.
let pat = if let Some(hash_idx) = from.find('#') {
let prefix = regex::escape(&from[..hash_idx + 1]);
let hex = regex::escape(&from[hash_idx + 1..]);
format!("{prefix}(?i){hex}")
} else {
regex::escape(from)
};
let re = match Regex::new(&pat) {
Ok(r) => r,
Err(_) => continue,
};
out = re.replace_all(&out, to.as_str()).to_string();
}
out
}