Skip to content

Commit 3f4d69f

Browse files
authored
Harden pcb import passive value parsing (#533)
1 parent 271f2ff commit 3f4d69f

File tree

2 files changed

+98
-11
lines changed

2 files changed

+98
-11
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.
88

99
## [Unreleased]
1010

11+
### Fixed
12+
13+
- Harden `pcb import` passive value parsing (e.g. `1 uF`, `2,2uF`, `1uF/16V`, `10 kΩ`, `R10`) so generic R/C auto-promotion is applied consistently.
14+
1115
## [0.3.40] - 2026-02-12
1216

1317
### Added

crates/pcb/src/import/semantic.rs

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -651,20 +651,25 @@ fn contains_code(haystack: &str, code: &str) -> bool {
651651

652652
fn parse_resistance(raw: &str) -> Option<String> {
653653
// Try common project convention: "R_10k_0402"
654-
let parts: Vec<&str> = raw
655-
.split(|c: char| c == '_' || c == '-' || c.is_whitespace())
656-
.collect();
654+
let parts = tokenize_import_value(raw);
657655
if parts.len() >= 2 && parts[0].eq_ignore_ascii_case("r") {
658656
if let Some(v) = parse_resistance_token(parts[1]) {
659657
return Some(v);
660658
}
661659
}
662-
for part in parts {
660+
for part in &parts {
663661
if let Some(v) = parse_resistance_token(part) {
664662
return Some(v);
665663
}
666664
}
667-
None
665+
// Avoid merging a standalone refdes prefix token like "R" with following
666+
// value/package tokens ("R"+"10" -> "R10", "R"+"0402" -> "R0402").
667+
let merge_parts = if parts.first().is_some_and(|p| p.eq_ignore_ascii_case("r")) {
668+
&parts[1..]
669+
} else {
670+
&parts[..]
671+
};
672+
parse_from_merged_tokens(merge_parts, parse_resistance_token)
668673
}
669674

670675
fn parse_resistance_token(token: &str) -> Option<String> {
@@ -674,7 +679,10 @@ fn parse_resistance_token(token: &str) -> Option<String> {
674679
}
675680
let mut s = t.to_ascii_uppercase();
676681
s = s.replace('Ω', "OHM");
682+
s = s.replace('Ω', "OHM");
677683
s = s.replace('µ', "U");
684+
s = s.replace('μ', "U");
685+
s = s.replace(',', ".");
678686
s = s.replace("OHMS", "OHM");
679687
s = s.replace("KOHM", "K");
680688
s = s.replace("MOHM", "M");
@@ -691,6 +699,14 @@ fn parse_resistance_token(token: &str) -> Option<String> {
691699
return None;
692700
}
693701

702+
// R10 / R005 => 0.10 / 0.005
703+
if let Some(frac) = s.strip_prefix('R') {
704+
if !frac.is_empty() && frac.chars().all(|c| c.is_ascii_digit()) {
705+
let out = format!("0.{frac}");
706+
return Some(normalize_decimal_string(&out));
707+
}
708+
}
709+
694710
// Reject tokens that contain capacitance-like units.
695711
if s.contains("UF") || s.contains("NF") || s.contains("PF") {
696712
return None;
@@ -759,20 +775,18 @@ fn parse_resistance_token(token: &str) -> Option<String> {
759775

760776
fn parse_capacitance(raw: &str) -> Option<String> {
761777
// Try common project convention: "C_100n_0402"
762-
let parts: Vec<&str> = raw
763-
.split(|c: char| c == '_' || c == '-' || c.is_whitespace())
764-
.collect();
778+
let parts = tokenize_import_value(raw);
765779
if parts.len() >= 2 && parts[0].eq_ignore_ascii_case("c") {
766780
if let Some(v) = parse_capacitance_token(parts[1]) {
767781
return Some(v);
768782
}
769783
}
770-
for part in parts {
784+
for part in &parts {
771785
if let Some(v) = parse_capacitance_token(part) {
772786
return Some(v);
773787
}
774788
}
775-
None
789+
parse_from_merged_tokens(&parts, parse_capacitance_token)
776790
}
777791

778792
fn parse_capacitance_token(token: &str) -> Option<String> {
@@ -782,6 +796,8 @@ fn parse_capacitance_token(token: &str) -> Option<String> {
782796
}
783797
let mut s = t.to_ascii_uppercase();
784798
s = s.replace('µ', "U");
799+
s = s.replace('μ', "U");
800+
s = s.replace(',', ".");
785801
s = s.replace(' ', "");
786802

787803
// Reject tokens that look like a resistance designation (prevents "10K" being treated as cap).
@@ -828,6 +844,57 @@ fn parse_capacitance_token(token: &str) -> Option<String> {
828844
None
829845
}
830846

847+
fn parse_from_merged_tokens(
848+
parts: &[&str],
849+
parse_token: fn(&str) -> Option<String>,
850+
) -> Option<String> {
851+
for width in [2usize, 3usize] {
852+
if parts.len() < width {
853+
continue;
854+
}
855+
for i in 0..=parts.len() - width {
856+
let merged = parts[i..i + width].join("");
857+
if let Some(v) = parse_token(&merged) {
858+
return Some(v);
859+
}
860+
}
861+
}
862+
None
863+
}
864+
865+
fn tokenize_import_value(raw: &str) -> Vec<&str> {
866+
raw.split(|c: char| {
867+
c == '_'
868+
|| c == '-'
869+
|| c == '/'
870+
|| c == ':'
871+
|| c == '|'
872+
|| c == '('
873+
|| c == ')'
874+
|| c == '['
875+
|| c == ']'
876+
|| c == '{'
877+
|| c == '}'
878+
|| c.is_whitespace()
879+
})
880+
.filter(|p| !p.is_empty())
881+
.collect()
882+
}
883+
884+
fn normalize_decimal_string(s: &str) -> String {
885+
let trimmed = s.trim();
886+
if let Some((a, b)) = trimmed.split_once('.') {
887+
let b = b.trim_end_matches('0');
888+
if b.is_empty() {
889+
a.to_string()
890+
} else {
891+
format!("{a}.{b}")
892+
}
893+
} else {
894+
trimmed.to_string()
895+
}
896+
}
897+
831898
fn is_number(s: &str) -> bool {
832899
let s = s.trim();
833900
if s.is_empty() {
@@ -912,7 +979,11 @@ mod tests {
912979
("4K7", "4.7k"),
913980
("49R9", "49.9"),
914981
("10R0", "10"),
982+
("R10", "0.1"),
915983
("1M", "1M"),
984+
("10 k", "10k"),
985+
("10 kΩ", "10k"),
986+
("10 kOhm", "10k"),
916987
("R_10k_0402", "10k"),
917988
("10ohm", "10"),
918989
("10Ω", "10"),
@@ -923,7 +994,14 @@ mod tests {
923994
"resistance parse: {raw}"
924995
);
925996
}
926-
for raw in ["Murata-BLM21PG_0805", "LED_G_0603", "100n", "0.1uF"] {
997+
for raw in [
998+
"Murata-BLM21PG_0805",
999+
"LED_G_0603",
1000+
"100n",
1001+
"0.1uF",
1002+
"R_10_0402",
1003+
"R_0402",
1004+
] {
9271005
assert!(parse_resistance(raw).is_none(), "expected none: {raw}");
9281006
}
9291007
}
@@ -934,6 +1012,11 @@ mod tests {
9341012
("100n", "100nF"),
9351013
("0.1uF", "0.1uF"),
9361014
("1UF", "1uF"),
1015+
("1 uF", "1uF"),
1016+
("2.2 uF", "2.2uF"),
1017+
("2,2uF", "2.2uF"),
1018+
("1uF/16V", "1uF"),
1019+
("1 μF", "1uF"),
9371020
("22P", "22pF"),
9381021
("C_100n_0402", "100nF"),
9391022
("10u", "10uF"),

0 commit comments

Comments
 (0)