use crate::{
archive::{ArchiveCreator, ArchiveExtractor},
colorful::{ColorfulOptions, ColorfulSilkscreenGenerator},
config::{Config, EdaType},
error::{Result, ResultExt, TransJlcError},
gerber::GerberProcessor,
patterns::{EdaPatterns, LayerType, PatternMatcher},
progress::ProgressTracker,
};
use anyhow::Context;
use rust_embed::RustEmbed;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
use tracing::{debug, info, warn};
#[derive(RustEmbed)]
#[folder = "Assets/"]
struct Asset;
pub struct Converter {
config: Config,
progress_tracker: ProgressTracker,
archive_extractor: ArchiveExtractor,
gerber_processor: GerberProcessor,
processed_files: HashMap<LayerType, PathBuf>,
extra_files: Vec<PathBuf>,
}
impl Converter {
pub fn new(config: Config) -> Self {
let progress_enabled = !config.no_progress;
Self {
config,
progress_tracker: ProgressTracker::new(progress_enabled),
archive_extractor: ArchiveExtractor::new(),
gerber_processor: GerberProcessor::new(),
processed_files: HashMap::new(),
extra_files: Vec::new(),
}
}
pub fn run(&mut self) -> Result<()> {
let start = std::time::Instant::now();
info!("Starting conversion process...");
self.config
.validate()
.context("Configuration validation failed")?;
let working_path = self
.extract_input_files()
.context("Failed to extract input files")?;
let files = self
.discover_files(&working_path)
.context("Failed to discover input files")?;
let patterns = self
.create_pattern_matcher(&files)
.context("Failed to create pattern matcher")?;
let eda_kind = patterns.name.to_lowercase();
let inject_header = self
.config
.inject_header_override()
.unwrap_or(eda_kind != "jlc");
let pass_through_unmatched = self
.config
.pass_through_unmatched_override()
.unwrap_or(eda_kind == "jlc");
self.gerber_processor = GerberProcessor::new().with_inject_header(inject_header);
info!(
"Using EDA={} inject_header={} pass_through_unmatched={}",
eda_kind, inject_header, pass_through_unmatched
);
self.process_files(&files, &patterns, &working_path, pass_through_unmatched)
.context("Failed to process files")?;
self.add_required_assets()
.context("Failed to add required assets")?;
self.generate_colorful_silkscreens()
.context("Failed to generate colorful silkscreen files")?;
self.create_output().context("Failed to create output")?;
info!("Conversion completed in {} ms", start.elapsed().as_millis());
Ok(())
}
fn extract_input_files(&mut self) -> Result<PathBuf> {
let progress = self.progress_tracker.create_spinner("Analyzing input...");
let working_path = self
.archive_extractor
.extract_if_needed(&self.config.path, !self.config.no_progress)
.with_path_context("analyze input", &self.config.path)?;
ProgressTracker::finish_progress(progress, "Input analysis completed");
Ok(working_path)
}
fn discover_files(&self, working_path: &Path) -> Result<Vec<PathBuf>> {
info!("Processing files in {}", working_path.display());
let mut files = fs::read_dir(working_path)
.with_path_context("read directory", working_path)?
.filter_map(|entry| {
entry.ok().and_then(|e| {
let path = e.path();
if path.is_file() {
Some(path)
} else {
None
}
})
})
.collect::<Vec<_>>();
files.sort();
info!("Discovered {} files", files.len());
debug!("Files found: {:?}", files);
if files.is_empty() {
return Err(TransJlcError::FileNotFound {
path: working_path.display().to_string(),
}
.into());
}
Ok(files)
}
fn create_pattern_matcher(&self, files: &[PathBuf]) -> Result<EdaPatterns> {
info!("Detecting EDA tool type for {} files...", files.len());
let patterns = match self.config.get_eda_type() {
EdaType::Auto => {
info!("Attempting to auto-detect an EDA format");
PatternMatcher::auto_detect_eda(files)?
}
EdaType::KiCad => {
info!("Using KiCad naming patterns");
PatternMatcher::create_kicad_patterns()
}
EdaType::Protel => {
info!("Using Protel naming patterns");
PatternMatcher::create_protel_patterns()
}
EdaType::Jlc => {
info!("Using JLC naming patterns");
PatternMatcher::create_jlc_patterns()
}
EdaType::Custom(name) => {
warn!("Using custom pattern matcher for: {}", name);
PatternMatcher::create_custom_patterns(name)
}
};
Ok(patterns)
}
fn process_files(
&mut self,
files: &[PathBuf],
patterns: &EdaPatterns,
working_path: &Path,
pass_through_unmatched: bool,
) -> Result<()> {
info!("Processing Gerber files...");
let progress = self
.progress_tracker
.create_conversion_progress(files.len());
let needs_g54_aperture_prefix = self.determine_g54_requirement(files, patterns)?;
for file in files {
self.process_single_file(
file,
patterns,
working_path,
needs_g54_aperture_prefix,
pass_through_unmatched,
)
.with_path_context("process file", file)?;
ProgressTracker::update_progress(&progress, 1, None);
}
ProgressTracker::finish_progress(progress, "File processing completed");
info!("Processed {} files", self.processed_files.len());
Ok(())
}
fn process_single_file(
&mut self,
file_path: &Path,
patterns: &EdaPatterns,
_working_path: &Path,
needs_g54_aperture_prefix: bool,
pass_through_unmatched: bool,
) -> Result<()> {
let filename = file_path
.file_name()
.and_then(|name| name.to_str())
.context("Invalid filename")?;
debug!("Processing file: {}", filename);
if filename == "PCB下单必读.txt" {
debug!("Skipping source ordering instruction asset: {}", filename);
return Ok(());
}
if let Some(layer_type) = patterns.match_filename(filename) {
info!("Matched {} to layer type: {:?}", filename, layer_type);
if matches!(layer_type, LayerType::Other) {
debug!("Skipping file matched as Other: {}", filename);
return Ok(());
}
if let Some(existing) = self.processed_files.get(&layer_type) {
warn!(
"Layer {:?} is already filled by {}; skipping {}",
layer_type,
existing.display(),
filename
);
return Ok(());
}
let output_filename = layer_type.to_jlc_filename();
let output_path = self.get_output_file_path(&output_filename);
let content =
fs::read_to_string(file_path).with_path_context("read file content", file_path)?;
let processed_content = if self.should_process_gerber(&layer_type) {
self.gerber_processor
.process_gerber_content(content, needs_g54_aperture_prefix)?
} else {
self.gerber_processor
.normalize_excellon_drill_content(content)?
};
self.write_output_file(&output_path, &processed_content)
.with_path_context("write output file", &output_path)?;
self.processed_files.insert(layer_type, output_path);
} else if pass_through_unmatched {
let output_path = self.get_output_file_path(filename);
self.copy_passthrough_file(file_path, &output_path)
.with_path_context("pass through unmatched file", file_path)?;
self.extra_files.push(output_path);
} else {
debug!("No pattern match for file: {}", filename);
}
Ok(())
}
fn determine_g54_requirement(&self, files: &[PathBuf], patterns: &EdaPatterns) -> Result<bool> {
for file in files {
let Some(filename) = file.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(layer_type) = patterns.match_filename(filename) else {
continue;
};
if !self.should_process_gerber(&layer_type) {
continue;
}
let content = fs::read_to_string(file).with_path_context("read file content", file)?;
if self
.gerber_processor
.has_missing_g54_aperture_prefix(&content)?
{
return Ok(true);
}
}
Ok(false)
}
fn should_process_gerber(&self, layer_type: &LayerType) -> bool {
!matches!(
layer_type,
LayerType::NpthThrough | LayerType::PthThrough | LayerType::PthThroughVia
)
}
fn get_output_file_path(&self, filename: &str) -> PathBuf {
self.get_working_output_dir().join(filename)
}
fn get_working_output_dir(&self) -> PathBuf {
if let Some(temp_path) = self.archive_extractor.temp_path() {
temp_path.to_path_buf()
} else {
self.config.output_path.clone()
}
}
fn write_output_file(&self, output_path: &Path, content: &str) -> Result<()> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).with_path_context("create output directory", parent)?;
}
fs::write(output_path, content).with_path_context("write file", output_path)?;
debug!("Written output file: {}", output_path.display());
Ok(())
}
fn copy_passthrough_file(&self, input_path: &Path, output_path: &Path) -> Result<()> {
if input_path == output_path {
debug!(
"Pass-through source and destination are identical: {}",
input_path.display()
);
return Ok(());
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).with_path_context("create output directory", parent)?;
}
fs::copy(input_path, output_path)
.with_path_context("copy pass-through file", output_path)?;
debug!("Pass-through file copied: {}", output_path.display());
Ok(())
}
fn add_required_assets(&mut self) -> Result<()> {
info!("Adding required assets");
const ASSET_NAME: &str = "PCB下单必读.txt";
let content =
Asset::get(ASSET_NAME).context("Required asset not found in embedded files")?;
let output_path = self.get_working_output_dir().join(ASSET_NAME);
fs::write(&output_path, content.data.as_ref())
.with_path_context("write required asset", &output_path)?;
self.processed_files.insert(LayerType::Other, output_path);
info!("Added required asset: {}", ASSET_NAME);
Ok(())
}
fn create_output(&self) -> Result<()> {
info!("Creating final output");
let file_paths = self.collect_output_files();
if self.config.zip {
let zip_path = self
.config
.output_path
.join(format!("{}.zip", self.config.zip_name));
ArchiveCreator::create_zip(&file_paths, &zip_path, !self.config.no_progress)?;
info!("Created ZIP archive: {}", zip_path.display());
} else {
self.copy_files_to_output(&file_paths)?;
info!("Copied {} files to output directory", file_paths.len());
}
Ok(())
}
fn copy_files_to_output(&self, file_paths: &[PathBuf]) -> Result<()> {
let progress = self
.progress_tracker
.create_file_progress(file_paths.len(), "Copying files to output");
fs::create_dir_all(&self.config.output_path)
.with_path_context("create output directory", &self.config.output_path)?;
for file_path in file_paths {
if let Some(filename) = file_path.file_name() {
let dest_path = self.config.output_path.join(filename);
if file_path != &dest_path {
fs::copy(file_path, &dest_path)
.with_path_context("copy file to output", &dest_path)?;
}
ProgressTracker::update_progress(&progress, 1, None);
}
}
ProgressTracker::finish_progress(progress, "File copying completed");
Ok(())
}
fn collect_output_files(&self) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut files = Vec::new();
for path in self.processed_files.values().chain(self.extra_files.iter()) {
if seen.insert(path.clone()) {
files.push(path.clone());
}
}
files
}
pub fn get_conversion_stats(&self) -> ConversionStats {
ConversionStats {
total_files_processed: self.processed_files.len(),
total_extra_files: self.extra_files.len(),
layer_types_found: self.processed_files.keys().cloned().collect(),
output_format: if self.config.zip { "ZIP" } else { "Files" }.to_string(),
}
}
fn generate_colorful_silkscreens(&mut self) -> Result<()> {
if self.config.top_color_image.is_none() && self.config.bottom_color_image.is_none() {
return Ok(());
}
let Some(outline_path) = self.processed_files.get(&LayerType::BoardOutline) else {
return Err(TransJlcError::FileNotFound {
path: "Board outline (Gerber_BoardOutlineLayer.GKO) not found; required for colorful silkscreen".to_string(),
}
.into());
};
info!("Generating colorful silkscreen files");
let options = ColorfulOptions {
top_image: self.config.top_color_image.clone(),
bottom_image: self.config.bottom_color_image.clone(),
top_solder_mask: self.processed_files.get(&LayerType::TopSoldermask).cloned(),
bottom_solder_mask: self
.processed_files
.get(&LayerType::BottomSoldermask)
.cloned(),
};
let generator = ColorfulSilkscreenGenerator::new(options);
let output_dir = self.get_working_output_dir();
let generated_files = generator
.generate(outline_path, &output_dir)
.with_path_context("generate colorful silkscreen", outline_path)?;
for (layer, path) in generated_files {
self.processed_files.insert(layer, path);
}
Ok(())
}
}
#[derive(Debug)]
pub struct ConversionStats {
pub total_files_processed: usize,
pub total_extra_files: usize,
pub layer_types_found: Vec<LayerType>,
pub output_format: String,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_converter_creation() {
let config = Config {
eda: "kicad".to_string(),
path: PathBuf::from("."),
output_path: PathBuf::from("./output"),
zip: false,
zip_name: "test".to_string(),
verbose: false,
no_progress: true,
top_color_image: None,
bottom_color_image: None,
inject_header: false,
no_inject_header: false,
passthrough: false,
no_passthrough: false,
};
let converter = Converter::new(config);
assert!(converter.processed_files.is_empty());
}
#[test]
fn test_working_output_dir() {
let config = Config {
eda: "kicad".to_string(),
path: PathBuf::from("."),
output_path: PathBuf::from("./output"),
zip: false,
zip_name: "test".to_string(),
verbose: false,
no_progress: true,
top_color_image: None,
bottom_color_image: None,
inject_header: false,
no_inject_header: false,
passthrough: false,
no_passthrough: false,
};
let converter = Converter::new(config);
let working_dir = converter.get_working_output_dir();
assert_eq!(working_dir, PathBuf::from("./output"));
}
#[test]
fn test_should_process_gerber() {
let config = Config {
eda: "kicad".to_string(),
path: PathBuf::from("."),
output_path: PathBuf::from("./output"),
zip: false,
zip_name: "test".to_string(),
verbose: false,
no_progress: true,
top_color_image: None,
bottom_color_image: None,
inject_header: false,
no_inject_header: false,
passthrough: false,
no_passthrough: false,
};
let converter = Converter::new(config);
assert!(!converter.should_process_gerber(&LayerType::NpthThrough));
assert!(!converter.should_process_gerber(&LayerType::PthThrough));
assert!(!converter.should_process_gerber(&LayerType::PthThroughVia));
assert!(converter.should_process_gerber(&LayerType::TopCopper));
assert!(converter.should_process_gerber(&LayerType::BoardOutline));
assert!(converter.should_process_gerber(&LayerType::InnerLayer(1)));
}
#[test]
fn test_conversion_stats() {
let config = Config {
eda: "kicad".to_string(),
path: PathBuf::from("."),
output_path: PathBuf::from("./output"),
zip: true,
zip_name: "test".to_string(),
verbose: false,
no_progress: true,
top_color_image: None,
bottom_color_image: None,
inject_header: false,
no_inject_header: false,
passthrough: false,
no_passthrough: false,
};
let mut converter = Converter::new(config);
converter
.processed_files
.insert(LayerType::TopCopper, PathBuf::from("top.gtl"));
converter
.processed_files
.insert(LayerType::BottomCopper, PathBuf::from("bottom.gbl"));
let stats = converter.get_conversion_stats();
assert_eq!(stats.total_files_processed, 2);
assert_eq!(stats.output_format, "ZIP");
assert!(stats.layer_types_found.contains(&LayerType::TopCopper));
assert!(stats.layer_types_found.contains(&LayerType::BottomCopper));
}
#[test]
fn test_determine_g54_requirement() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_missing = temp_dir.path().join("project-F_Cu.gbr");
let file_prefixed = temp_dir.path().join("project-B_Cu.gbr");
fs::write(&file_missing, "G04*\nD10*\n").expect("Failed to write missing file");
fs::write(&file_prefixed, "G04*\nG54D11*\n").expect("Failed to write prefixed file");
let config = Config {
eda: "kicad".to_string(),
path: PathBuf::from("."),
output_path: PathBuf::from("./output"),
zip: false,
zip_name: "test".to_string(),
verbose: false,
no_progress: true,
top_color_image: None,
bottom_color_image: None,
inject_header: false,
no_inject_header: false,
passthrough: false,
no_passthrough: false,
};
let converter = Converter::new(config);
let patterns = PatternMatcher::create_kicad_patterns();
let files = vec![file_missing.clone(), file_prefixed.clone()];
let needs_prefix = converter
.determine_g54_requirement(&files, &patterns)
.expect("Detection should succeed");
assert!(needs_prefix);
fs::write(&file_missing, "G04*\nG54D10*\n").expect("Failed to rewrite missing file");
let needs_prefix = converter
.determine_g54_requirement(&files, &patterns)
.expect("Detection should succeed");
assert!(!needs_prefix);
}
}