diff --git a/Cargo.lock b/Cargo.lock index 24b6200..e47ad7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,6 +2689,8 @@ dependencies = [ "anyhow", "clap", "qr-core", + "serde", + "serde_json", ] [[package]] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 332e404..c8ede61 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,3 +13,5 @@ path = "src/main.rs" qr-core = { path = "../core" } clap = { version = "4", features = ["derive"] } anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/cli/src/main.rs b/cli/src/main.rs index a1453df..cb4ca36 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,9 @@ use clap::Parser; use qr_core::qr::{QrCode, QrConfig, VersionMode}; +use qr_core::text_builder; use qr_core::version::EcLevel; +use serde::Deserialize; +use std::fs; use std::path::Path; #[derive(Parser)] @@ -9,10 +12,10 @@ use std::path::Path; about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现" )] struct Args { - /// 要编码的内容(编码模式) + /// 快捷编码内容 content: Option, - /// 解码图片文件 (PNG/JPEG/WebP),与编码模式互斥 + /// 解码图片文件 (PNG/JPEG/WebP) #[arg(short = 'd', long)] decode: Option, @@ -40,71 +43,156 @@ struct Args { #[arg(long)] invert: bool, - /// 前景色 "#RRGGBB" [default: "#000000"] + /// 前景色 "#RRGGBB" #[arg(long)] fg: Option, - /// 背景色 "#RRGGBB" [default: "#FFFFFF"] + /// 背景色 "#RRGGBB" #[arg(long)] bg: Option, + + /// Logo 图片文件 + #[arg(long)] + logo: Option, + + // ---- 编码模式参数 ---- + /// 编码模式 [text/url/wifi/vcard/email/phone/sms/batch] + #[arg(long)] + mode: Option, + + /// WiFi SSID + #[arg(long)] + ssid: Option, + + /// WiFi 密码 + #[arg(long)] + password: Option, + + /// WiFi 加密方式 [default: WPA] + #[arg(long, default_value = "WPA")] + encryption: String, + + /// 隐藏 WiFi 网络 + #[arg(long)] + hidden: bool, + + /// 姓名 (vCard) + #[arg(long)] + name: Option, + + /// 电话 (vCard) + #[arg(long)] + phone: Option, + + /// 邮箱 (vCard) + #[arg(long)] + email: Option, + + /// 公司 (vCard) + #[arg(long)] + company: Option, + + /// 地址 (vCard) + #[arg(long)] + address: Option, + + /// 收件人 (Email) + #[arg(long)] + to: Option, + + /// 主题 (Email) + #[arg(long)] + subject: Option, + + /// 正文 (Email) + #[arg(long)] + body: Option, + + /// 电话号码 (Phone/SMS) + #[arg(long)] + number: Option, + + /// 短信内容 (SMS) + #[arg(long)] + message: Option, + + /// URL 链接 + #[arg(long)] + url: Option, + + /// 批量输入文件 (JSON/CSV) + #[arg(long)] + batch: Option, + + /// 批量输出目录 + #[arg(long)] + output_dir: Option, +} + +#[derive(Deserialize)] +struct BatchEntry { + content: Option, + level: Option, + ssid: Option, + password: Option, + encryption: Option, + #[serde(default)] + hidden: Option, + name: Option, + phone: Option, + email: Option, + company: Option, + address: Option, + to: Option, + subject: Option, + body: Option, + number: Option, + message: Option, + url: Option, } fn main() -> anyhow::Result<()> { let args = Args::parse(); - // 解码模式 if let Some(path) = args.decode { return do_decode(&path); } - // 编码模式 - let content = args - .content - .as_deref() - .ok_or_else(|| anyhow::anyhow!("请提供编码内容,或使用 --decode <文件> 解码图片"))?; - do_encode(content, &args) -} + if let Some(ref batch_file) = args.batch { + return do_batch(batch_file, &args); + } -fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { - let level = match args.level.to_uppercase().as_str() { - "L" => EcLevel::L, - "M" => EcLevel::M, - "Q" => EcLevel::Q, - "H" => EcLevel::H, - _ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", args.level), - }; - - let version = match args.version { - Some(v) => { - if !(1..=40).contains(&v) { - anyhow::bail!("无效版本号: {}。支持 1-40", v); - } - VersionMode::Fixed(v) - } - None => VersionMode::Auto, - }; + let text = build_text_from_args(&args)?; + let level = parse_level(&args.level)?; + let logo_bytes = args + .logo + .as_ref() + .map(|p| fs::read(p)) + .transpose() + .map_err(|e| anyhow::anyhow!("无法读取 logo 文件: {e}"))?; let config = QrConfig { level, - version, + version: match args.version { + Some(v) => { + if !(1..=40).contains(&v) { + anyhow::bail!("无效版本号: {}。支持 1-40", v); + } + VersionMode::Fixed(v) + } + None => VersionMode::Auto, + }, margin: args.margin, fg_color: args.fg.clone(), bg_color: args.bg.clone(), }; - let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?; + let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?; match &args.output { Some(path) => { - let path_obj = Path::new(path); - if path_obj - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名"); - } - - let ext = path_obj + check_path(path)?; + let ext = Path::new(path) .extension() .and_then(|e| e.to_str()) .unwrap_or("") @@ -112,8 +200,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { match ext.as_str() { "png" => { - let bytes = qr.to_png_bytes(args.size, None)?; - std::fs::write(path, bytes)?; + let bytes = qr.to_png_bytes(args.size, logo_bytes.as_deref())?; + fs::write(path, bytes)?; println!( "已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)", path, @@ -124,8 +212,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { ); } "svg" => { - let svg = qr.to_svg(None); - std::fs::write(path, svg)?; + let svg = qr.to_svg(logo_bytes.as_deref()); + fs::write(path, svg)?; println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0); } _ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext), @@ -139,12 +227,71 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { Ok(()) } +fn build_text_from_args(args: &Args) -> anyhow::Result { + match args.mode.as_deref() { + Some("wifi") => { + let ssid = args + .ssid + .as_deref() + .ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?; + let pwd = args.password.as_deref().unwrap_or(""); + Ok(text_builder::build_wifi_text( + ssid, + pwd, + &args.encryption, + args.hidden, + )) + } + Some("vcard") => Ok(text_builder::build_vcard_text( + args.name.as_deref().unwrap_or(""), + args.phone.as_deref().unwrap_or(""), + args.email.as_deref().unwrap_or(""), + args.company.as_deref().unwrap_or(""), + args.address.as_deref().unwrap_or(""), + )), + Some("email") => { + let to = args + .to + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?; + Ok(text_builder::build_email_text( + to, + args.subject.as_deref().unwrap_or(""), + args.body.as_deref().unwrap_or(""), + )) + } + Some("phone") => { + let num = args + .number + .as_deref() + .ok_or_else(|| anyhow::anyhow!("电话模式需要 --number"))?; + Ok(text_builder::build_phone_text(num)) + } + Some("sms") => { + let num = args + .number + .as_deref() + .ok_or_else(|| anyhow::anyhow!("短信模式需要 --number"))?; + Ok(text_builder::build_sms_text( + num, + args.message.as_deref().unwrap_or(""), + )) + } + Some("url") => args + .url + .clone() + .ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")), + Some(m) => anyhow::bail!("未知模式: {m}。支持 text/url/wifi/vcard/email/phone/sms/batch"), + None => args + .content + .clone() + .ok_or_else(|| anyhow::anyhow!("请提供编码内容或使用 --mode 指定模式")), + } +} + fn do_decode(path: &str) -> anyhow::Result<()> { - let bytes = - std::fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?; - + let bytes = fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?; let result = qr_core::decoder::decode_image(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?; - println!("解码成功:"); println!(" 文本: {}", result.text); println!(" 版本: {}", result.version); @@ -153,6 +300,176 @@ fn do_decode(path: &str) -> anyhow::Result<()> { if result.errors_corrected > 0 { println!(" 纠正错误: {} 码字", result.errors_corrected); } - + Ok(()) +} + +fn do_batch(file: &str, args: &Args) -> anyhow::Result<()> { + let input = fs::read_to_string(file) + .map_err(|e| anyhow::anyhow!("无法读取批量文件 '{}': {}", file, e))?; + + let entries: Vec = serde_json::from_str(&input) + .or_else(|_| parse_csv(&input)) + .map_err(|e| anyhow::anyhow!("无法解析输入: {e}\n支持 JSON 数组或 CSV 格式"))?; + + let out_dir = args.output_dir.as_deref().unwrap_or("batch_output"); + fs::create_dir_all(out_dir)?; + + for (i, entry) in entries.iter().enumerate() { + let text = batch_entry_to_text(entry)?; + let level = entry + .level + .as_deref() + .map(parse_level) + .unwrap_or(Ok(EcLevel::M))?; + + let config = QrConfig { + level, + version: VersionMode::Auto, + margin: args.margin, + fg_color: args.fg.clone(), + bg_color: args.bg.clone(), + }; + + let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("{e}"))?; + let path = format!("{}/qr_{:04}.png", out_dir, i + 1); + let bytes = qr.to_png_bytes(args.size, None)?; + fs::write(&path, bytes)?; + println!("[{}/{}] {}", i + 1, entries.len(), path); + } + + println!("批量生成完成: {} 个 QR 码 → {}", entries.len(), out_dir); + Ok(()) +} + +fn batch_entry_to_text(entry: &BatchEntry) -> anyhow::Result { + if let Some(c) = &entry.content { + return Ok(c.clone()); + } + if let Some(u) = &entry.url { + return Ok(u.clone()); + } + if let Some(s) = &entry.ssid { + let p = entry.password.as_deref().unwrap_or(""); + let e = entry.encryption.as_deref().unwrap_or("WPA"); + let h = entry.hidden.unwrap_or(false); + return Ok(text_builder::build_wifi_text(s, p, e, h)); + } + if let Some(n) = &entry.name { + let ph = entry.phone.as_deref().unwrap_or(""); + let em = entry.email.as_deref().unwrap_or(""); + let co = entry.company.as_deref().unwrap_or(""); + let ad = entry.address.as_deref().unwrap_or(""); + return Ok(text_builder::build_vcard_text(n, ph, em, co, ad)); + } + if let Some(t) = &entry.to { + let s = entry.subject.as_deref().unwrap_or(""); + let b = entry.body.as_deref().unwrap_or(""); + return Ok(text_builder::build_email_text(t, s, b)); + } + if let Some(n) = &entry.number { + if let Some(m) = &entry.message { + return Ok(text_builder::build_sms_text(n, m)); + } + return Ok(text_builder::build_phone_text(n)); + } + anyhow::bail!("无法识别的条目格式") +} + +fn parse_csv(input: &str) -> Result, String> { + let mut lines = input.lines(); + let header = lines.next().ok_or("CSV 为空")?; + let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect(); + let mut entries = Vec::new(); + for line in lines { + if line.trim().is_empty() { + continue; + } + let values: Vec = line + .split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .collect(); + let mut content = None; + let mut level = None; + let mut ssid = None; + let mut password = None; + let mut encryption = None; + let mut hidden = None; + let mut name = None; + let mut phone = None; + let mut email = None; + let mut company = None; + let mut address = None; + let mut to = None; + let mut subject = None; + let mut body = None; + let mut number = None; + let mut message = None; + let mut url = None; + + for (i, col) in columns.iter().enumerate() { + let val = values.get(i).cloned(); + match *col { + "content" => content = val, + "level" => level = val, + "ssid" => ssid = val, + "password" => password = val, + "encryption" => encryption = val, + "hidden" => hidden = val.map(|v| v == "true"), + "name" => name = val, + "phone" => phone = val, + "email" => email = val, + "company" => company = val, + "address" => address = val, + "to" => to = val, + "subject" => subject = val, + "body" => body = val, + "number" => number = val, + "message" => message = val, + "url" => url = val, + _ => {} + } + } + + entries.push(BatchEntry { + content, + level, + ssid, + password, + encryption, + hidden, + name, + phone, + email, + company, + address, + to, + subject, + body, + number, + message, + url, + }); + } + Ok(entries) +} + +fn parse_level(s: &str) -> anyhow::Result { + match s.to_uppercase().as_str() { + "L" => Ok(EcLevel::L), + "M" => Ok(EcLevel::M), + "Q" => Ok(EcLevel::Q), + "H" => Ok(EcLevel::H), + _ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", s), + } +} + +fn check_path(path: &str) -> anyhow::Result<()> { + let path_obj = Path::new(path); + if path_obj + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名"); + } Ok(()) } diff --git a/core/src/lib.rs b/core/src/lib.rs index 9dff5de..b0f94e5 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -4,4 +4,5 @@ pub mod encoder; pub mod matrix; pub mod qr; pub mod render; +pub mod text_builder; pub mod version; diff --git a/core/src/render/png.rs b/core/src/render/png.rs index baf1dea..fed48f6 100644 --- a/core/src/render/png.rs +++ b/core/src/render/png.rs @@ -26,8 +26,7 @@ fn fill_module( /// 在 QR 码 PNG 缓冲区中央叠加 logo fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), String> { - let logo = - image::load_from_memory(logo_bytes).map_err(|e| format!("Logo 加载失败: {e}"))?; + let logo = image::load_from_memory(logo_bytes).map_err(|e| format!("Logo 加载失败: {e}"))?; let logo = logo.to_rgba8(); let img_w = img.width(); @@ -39,12 +38,7 @@ fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> R return Ok(()); // 太小,跳过 } - let resized = imageops::resize( - &logo, - logo_size, - logo_size, - imageops::FilterType::Lanczos3, - ); + let resized = imageops::resize(&logo, logo_size, logo_size, imageops::FilterType::Lanczos3); let x = (img_w - logo_size) / 2; let y = (img_h - logo_size) / 2; @@ -79,7 +73,15 @@ pub fn render_png( false }; - fill_module(&mut img, x, y, module_size as u32, is_dark, &qr.fg_color, &qr.bg_color); + fill_module( + &mut img, + x, + y, + module_size as u32, + is_dark, + &qr.fg_color, + &qr.bg_color, + ); } } diff --git a/core/src/text_builder.rs b/core/src/text_builder.rs new file mode 100644 index 0000000..72a5996 --- /dev/null +++ b/core/src/text_builder.rs @@ -0,0 +1,97 @@ +//! QR 编码文本构造工具 +//! +//! 集中管理各模式的文本格式(与 gui 前端 `utils/qrText.ts` 功能一致) + +/// 构造 WiFi 连接字符串 +pub fn build_wifi_text(ssid: &str, password: &str, encryption: &str, hidden: bool) -> String { + let h = if hidden { "H:true;" } else { "" }; + format!("WIFI:T:{encryption};S:{ssid};P:{password};{h};") +} + +/// 构造 vCard 字符串 +pub fn build_vcard_text( + name: &str, + phone: &str, + email: &str, + company: &str, + address: &str, +) -> String { + format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}\nADR:{address}\nEND:VCARD") +} + +/// 构造 mailto 链接 +pub fn build_email_text(to: &str, subject: &str, body: &str) -> String { + let subject_enc = urlencoding(subject); + let body_enc = urlencoding(body); + format!("mailto:{to}?subject={subject_enc}&body={body_enc}") +} + +/// 构造电话链接 +pub fn build_phone_text(number: &str) -> String { + format!("tel:{number}") +} + +/// 构造短信链接 +pub fn build_sms_text(number: &str, message: &str) -> String { + format!("smsto:{number}:{message}") +} + +/// 简易 URL 编码(仅编码特殊字符) +fn urlencoding(s: &str) -> String { + s.chars() + .map(|c| match c { + ' ' => "%20".into(), + '&' => "%26".into(), + '=' => "%3D".into(), + '#' => "%23".into(), + '%' => "%25".into(), + '+' => "%2B".into(), + '\n' => "%0A".into(), + '\r' => "%0D".into(), + _ => c.to_string(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_wifi_text() { + let text = build_wifi_text("MyWiFi", "pass123", "WPA", false); + assert!(text.contains("WIFI:T:WPA;S:MyWiFi;P:pass123;")); + } + + #[test] + fn test_build_wifi_hidden() { + let text = build_wifi_text("HiddenNet", "secret", "WPA2", true); + assert!(text.contains("H:true;")); + } + + #[test] + fn test_build_vcard_text() { + let text = build_vcard_text("张三", "13800138000", "a@b.com", "公司", "北京"); + assert!(text.contains("BEGIN:VCARD")); + assert!(text.contains("FN:张三")); + assert!(text.contains("END:VCARD")); + } + + #[test] + fn test_build_email_text() { + let text = build_email_text("a@b.com", "Hello World", "Test body"); + assert!(text.starts_with("mailto:a@b.com")); + assert!(text.contains("Hello%20World")); + } + + #[test] + fn test_build_phone_text() { + assert_eq!(build_phone_text("13800138000"), "tel:13800138000"); + } + + #[test] + fn test_build_sms_text() { + let text = build_sms_text("13800138000", "Hi"); + assert_eq!(text, "smsto:13800138000:Hi"); + } +}