From cd75141037d172ebd304376813787005b14dbf89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sun, 21 Jun 2026 15:09:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P0-P5=20=E5=85=A8=E9=9D=A2=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 thiserror 类型化错误: 新增 core/src/error.rs QrError 枚举, 全链 String -> QrError, 包括 EmptyInput/InvalidVersion/DataTooLong/DecodeFail 等 8 种变体 P2 text_builder Tauri 统一: 新增 build_qr_text Tauri command, 删除前端 qrText.ts, 所有 mode 组件改为 invoke 调用 Rust 端构建文本 P3 QrConfig 颜色字段移除: 从 QrConfig/QrCode 移除 fg_color/bg_color, 改为 to_svg/to_image_bytes 参数传递 P4 前端 4 项合并: Context 拆分为 StateContext+DispatchContext (H10), 新建 useModeForm 通用 hook (M11), VCardMode grid-cols-2 网格布局 (M13), persistHistory/loadHistory 迁至 utils/storage.ts (L9) P5 算法优化: MaskedView 懒计算替代 8 次 Matrix 克隆 (H9), encoding_rs 精确 Kanji Shift JIS 映射 (H12) 验证: cargo check+clippy 通过, 81+24+7 全部测试通过 --- Cargo.lock | 3 + cli/src/main.rs | 386 +++++++++++++----- core/Cargo.toml | 2 + core/src/decoder/detect.rs | 11 +- core/src/decoder/format.rs | 33 +- core/src/decoder/image.rs | 7 +- core/src/decoder/mod.rs | 20 +- core/src/decoder/mode_decode.rs | 231 +++-------- core/src/decoder/rs_decode.rs | 35 +- core/src/encoder/mode.rs | 101 ++--- core/src/error.rs | 56 +++ core/src/lib.rs | 3 + core/src/matrix/mask.rs | 238 +++++++---- core/src/qr.rs | 112 ++--- core/src/render/image.rs | 18 +- core/src/render/svg.rs | 16 +- core/src/text_builder.rs | 13 +- core/src/version.rs | 16 + core/tests/integration_test.rs | 11 +- examples/basic_qr.rs | 12 +- examples/custom_config.rs | 7 +- examples/high_ecc.rs | 9 +- gui/src-frontend/src/App.tsx | 4 +- .../src/__tests__/qrContext.test.tsx | 36 +- gui/src-frontend/src/__tests__/qrText.test.ts | 109 ----- .../src/components/ExportPanel.tsx | 12 +- .../src/components/HistoryList.tsx | 7 +- gui/src-frontend/src/components/ModePanel.tsx | 5 +- gui/src-frontend/src/components/QrPreview.tsx | 55 ++- gui/src-frontend/src/hooks/useModeForm.ts | 36 ++ gui/src-frontend/src/hooks/useQrEncode.ts | 80 ++-- gui/src-frontend/src/modes/EmailMode.tsx | 19 +- gui/src-frontend/src/modes/PhoneMode.tsx | 18 +- gui/src-frontend/src/modes/SmsMode.tsx | 19 +- gui/src-frontend/src/modes/TextMode.tsx | 5 +- gui/src-frontend/src/modes/UrlMode.tsx | 5 +- gui/src-frontend/src/modes/VCardMode.tsx | 49 +-- gui/src-frontend/src/modes/WifiMode.tsx | 24 +- gui/src-frontend/src/store/qrContext.tsx | 34 +- gui/src-frontend/src/types/index.ts | 12 +- gui/src-frontend/src/utils/qrText.ts | 75 ---- gui/src-frontend/src/utils/storage.ts | 38 ++ gui/src/lib.rs | 108 +++-- web/Cargo.toml | 2 +- web/src/main.rs | 122 ++++-- web/src/templates/index.html | 97 +++-- 46 files changed, 1283 insertions(+), 1028 deletions(-) create mode 100644 core/src/error.rs delete mode 100644 gui/src-frontend/src/__tests__/qrText.test.ts create mode 100644 gui/src-frontend/src/hooks/useModeForm.ts delete mode 100644 gui/src-frontend/src/utils/qrText.ts create mode 100644 gui/src-frontend/src/utils/storage.ts diff --git a/Cargo.lock b/Cargo.lock index 6786583..8e81688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3106,8 +3106,10 @@ dependencies = [ name = "qr-core" version = "0.1.0" dependencies = [ + "encoding_rs", "image", "serde", + "thiserror 2.0.18", ] [[package]] @@ -4632,6 +4634,7 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", "tower", "tower-layer", diff --git a/cli/src/main.rs b/cli/src/main.rs index f169424..515a165 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::{bail, Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; use indicatif::{ProgressBar, ProgressStyle}; @@ -49,19 +50,25 @@ enum Command { #[derive(clap::Args, Clone)] struct EncodeOpts { - #[arg(short = 'l', long, default_value = "M")] level: String, - #[arg(short = 'V', long, value_parser = clap::value_parser!(u8).range(1..=40))] version: Option, - #[arg(short = 's', long, default_value = "4")] size: u8, - #[arg(short = 'm', long, default_value = "4")] margin: u8, + #[arg(short = 'l', long, default_value = "M")] + level: String, + #[arg(short = 'V', long, value_parser = clap::value_parser!(u8).range(1..=40))] + version: Option, + #[arg(short = 's', long, default_value = "4", value_parser = clap::value_parser!(u8).range(1..=20))] + size: u8, + #[arg(short = 'm', long, default_value = "4")] + margin: u8, #[arg(long)] fg: Option, #[arg(long)] bg: Option, #[arg(long)] logo: Option, - #[arg(short = 'f', long, default_value = "png")] format: String, + #[arg(short = 'f', long, default_value = "png")] + format: String, #[arg(long)] mode: Option, // WiFi #[arg(long)] ssid: Option, #[arg(long)] password: Option, - #[arg(long, default_value = "WPA")] encryption: String, + #[arg(long, default_value = "WPA")] + encryption: String, #[arg(long)] hidden: bool, // vCard #[arg(long)] name: Option, @@ -90,31 +97,24 @@ struct EncodeOpts { #[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, -} - -// ──────────────────── 错误类型 ──────────────────── - -struct E { msg: String, code: i32 } -impl E { - fn new(m: impl Into, c: i32) -> Self { Self { msg: m.into(), code: c } } - fn exit(&self) -> ! { eprintln!("qrgen: {}", self.msg); process::exit(self.code); } -} -impl From for E { - fn from(e: std::io::Error) -> Self { E::new(format!("IO 错误: {e}"), 2) } -} -impl From for E { - fn from(e: image::ImageError) -> Self { E::new(format!("图像错误: {e}"), 2) } -} -macro_rules! bail { - ($msg:expr, $code:expr) => { return Err(E::new($msg, $code)) }; - ($fmt:expr, $code:expr, $($arg:tt)*) => { return Err(E::new(format!($fmt, $($arg)*), $code)) }; + 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, } // ──────────────────── 入口 ──────────────────── @@ -131,124 +131,238 @@ fn main() { Command::Encode { content, output, opts } => cmd_encode(&content, &output, &opts), Command::Decode { file } => cmd_decode(&file), }; - if let Err(e) = r { e.exit(); } + if let Err(e) = r { + eprintln!("qrgen: {:#}", e); + process::exit(1); + } } // ──────────────────── I/O 辅助 ──────────────────── -fn stdin_bytes() -> Result, E> { +/// 最大 stdin 读取量:10 MB +const STDIN_MAX: u64 = 10 * 1024 * 1024; + +fn stdin_bytes() -> Result> { let mut b = Vec::new(); - io::stdin().read_to_end(&mut b).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?; + io::stdin() + .take(STDIN_MAX) + .read_to_end(&mut b) + .with_context(|| "无法读取 stdin")?; Ok(b) } -fn stdin_text() -> Result { +fn stdin_text() -> Result { let mut s = String::new(); - io::stdin().read_to_string(&mut s).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?; + io::stdin() + .take(STDIN_MAX) + .read_to_string(&mut s) + .with_context(|| "无法读取 stdin")?; let t = s.trim().to_string(); - if t.is_empty() { bail!("stdin 为空", 2); } + if t.is_empty() { + bail!("stdin 为空"); + } Ok(t) } // ──────────────────── 编码 ──────────────────── -fn cmd_encode(content: &str, output: &Option, opts: &EncodeOpts) -> Result<(), E> { - let text = if content == "-" { stdin_text()? } else { content.to_string() }; +fn cmd_encode(content: &str, output: &Option, opts: &EncodeOpts) -> Result<()> { + let text = if content == "-" { + stdin_text()? + } else { + content.to_string() + }; let final_text = if let Some(m) = &opts.mode { build_mode(m, opts, &text)? } else if let Some(ref bf) = opts.batch { return do_batch(bf, opts); - } else { text }; + } else { + text + }; - let level = parse_level(&opts.level)?; - let logo = opts.logo.as_ref().map(std::fs::read).transpose() - .map_err(|e| E::new(format!("无法读取 logo: {e}"), 2))?; + let level: EcLevel = opts.level.parse().map_err(|e: String| anyhow::anyhow!(e))?; + let logo = opts + .logo + .as_ref() + .map(std::fs::read) + .transpose() + .with_context(|| "无法读取 logo")?; let config = QrConfig { - level, version: opts.version.map(VersionMode::Fixed).unwrap_or(VersionMode::Auto), - margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone(), + level, + version: opts + .version + .map(VersionMode::Fixed) + .unwrap_or(VersionMode::Auto), + margin: opts.margin, }; - let qr = QrCode::encode(&final_text, config).map_err(|e| E::new(format!("编码失败: {e}"), 1))?; + let qr = QrCode::encode(&final_text, config).map_err(|e| anyhow::anyhow!("编码失败: {e}"))?; + + let fg = opts.fg.as_deref(); + let bg = opts.bg.as_deref(); match output { Some(p) => { check_path(p)?; - let ext = Path::new(p).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let ext = Path::new(p) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if ext.is_empty() { + eprintln!("警告: 无法从扩展名推断输出格式,回退到 PNG"); + } match ext.as_str() { - "svg" => { std::fs::write(p, qr.to_svg(logo.as_deref()))?; eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0); } + "svg" => { + std::fs::write(p, qr.to_svg(logo.as_deref(), fg, bg))?; + eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0); + } _ => { let fmt = qr_core::render::image::OutputFormat::from_ext(&ext) .or_else(|| qr_core::render::image::OutputFormat::from_ext(&opts.format)) - .unwrap_or(qr_core::render::image::OutputFormat::Png); - std::fs::write(p, qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt))?)?; - eprintln!("已生成: {p} (版本 {}, {}×{}, {:?}, {})", qr.version.0, qr.size(), qr.size(), qr.level, fmt.extension()); + .unwrap_or_else(|| { + eprintln!("警告: 无法识别扩展名 '{}',回退到 PNG", ext); + qr_core::render::image::OutputFormat::Png + }); + std::fs::write( + p, + qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt), fg, bg)?, + )?; + eprintln!( + "已生成: {p} (版本 {}, {}×{}, {:?}, {})", + qr.version.0, + qr.size(), + qr.size(), + qr.level, + fmt.extension() + ); } } } - None => { println!("{}", qr.to_ascii(false)); } + None => { + println!("{}", qr.to_ascii(false)); + } } Ok(()) } -fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result { +fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result { match mode { "wifi" => { - let s = opts.ssid.as_deref().ok_or_else(|| E::new("WiFi 模式需要 --ssid", 1))?; - Ok(text_builder::build_wifi_text(s, opts.password.as_deref().unwrap_or(""), &opts.encryption, opts.hidden)) + let s = opts + .ssid + .as_deref() + .ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?; + Ok(text_builder::build_wifi_text( + s, + opts.password.as_deref().unwrap_or(""), + &opts.encryption, + opts.hidden, + )) } "vcard" => Ok(text_builder::build_vcard_text( - opts.name.as_deref().unwrap_or(""), opts.phone.as_deref().unwrap_or(""), - opts.email.as_deref().unwrap_or(""), opts.company.as_deref().unwrap_or(""), - opts.address.as_deref().unwrap_or(""), opts.title.as_deref().unwrap_or(""), - opts.vcard_url.as_deref().unwrap_or(""), opts.birthday.as_deref().unwrap_or(""), - opts.note.as_deref().unwrap_or(""), opts.photo.as_deref().unwrap_or(""), + opts.name.as_deref().unwrap_or(""), + opts.phone.as_deref().unwrap_or(""), + opts.email.as_deref().unwrap_or(""), + opts.company.as_deref().unwrap_or(""), + opts.address.as_deref().unwrap_or(""), + opts.title.as_deref().unwrap_or(""), + opts.vcard_url.as_deref().unwrap_or(""), + opts.birthday.as_deref().unwrap_or(""), + opts.note.as_deref().unwrap_or(""), + opts.photo.as_deref().unwrap_or(""), )), "email" => { - let t = opts.to.as_deref().ok_or_else(|| E::new("Email 模式需要 --to", 1))?; - Ok(text_builder::build_email_text(t, opts.subject.as_deref().unwrap_or(""), opts.body.as_deref().unwrap_or(""))) + let t = opts + .to + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?; + Ok(text_builder::build_email_text( + t, + opts.subject.as_deref().unwrap_or(""), + opts.body.as_deref().unwrap_or(""), + )) } - "phone" => Ok(text_builder::build_phone_text(opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?)), + "phone" => Ok(text_builder::build_phone_text( + opts.number + .as_deref() + .ok_or_else(|| anyhow::anyhow!("需要 --number"))?, + )), "sms" => Ok(text_builder::build_sms_text( - opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?, + opts.number + .as_deref() + .ok_or_else(|| anyhow::anyhow!("需要 --number"))?, opts.message.as_deref().unwrap_or(""), )), - "url" => opts.url.clone().ok_or_else(|| E::new("URL 模式需要 --url", 1)), + "url" => opts + .url + .clone() + .ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")), "text" => Ok(fb.to_string()), - _m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch", 1), + _m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch"), } } // ──────────────────── 解码 ──────────────────── -fn cmd_decode(file: &str) -> Result<(), E> { - let bytes = if file == "-" { stdin_bytes()? } else { std::fs::read(file)? }; - let r = qr_core::decoder::decode_image(&bytes).map_err(|e| E::new(format!("解码失败: {e}"), 1))?; +fn cmd_decode(file: &str) -> Result<()> { + if file != "-" { + check_path(file)?; + } + let bytes = if file == "-" { + stdin_bytes()? + } else { + std::fs::read(file)? + }; + let r = qr_core::decoder::decode_image(&bytes) + .map_err(|e| anyhow::anyhow!("解码失败: {e}"))?; println!("{}", r.text); - eprintln!("版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字", r.version, r.level, r.mask, r.errors_corrected); + eprintln!( + "版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字", + r.version, r.level, r.mask, r.errors_corrected + ); Ok(()) } // ──────────────────── 批量 ──────────────────── -fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> { +fn do_batch(file: &str, opts: &EncodeOpts) -> Result<()> { let input = std::fs::read_to_string(file) - .map_err(|e| E::new(format!("无法读取批量文件 '{file}': {e}"), 2))?; + .with_context(|| format!("无法读取批量文件 '{file}'"))?; let entries: Vec = serde_json::from_str(&input) .or_else(|_| parse_csv(&input)) - .map_err(|e| E::new(format!("解析失败: {e}"), 2))?; + .map_err(|e| anyhow::anyhow!("解析失败: {e}"))?; let out = opts.output_dir.as_deref().unwrap_or("batch_output"); - std::fs::create_dir_all(out).map_err(|e| E::new(format!("无法创建目录 '{out}': {e}"), 2))?; + std::fs::create_dir_all(out) + .with_context(|| format!("无法创建目录 '{out}'"))?; let total = entries.len(); let pb = ProgressBar::new(total as u64); - pb.set_style(ProgressStyle::default_bar().template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}").unwrap()); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}") + .unwrap(), + ); for (i, e) in entries.iter().enumerate() { let text = batch_text(e)?; - let lvl = e.level.as_deref().map(parse_level).unwrap_or(Ok(EcLevel::M))?; - let cfg = QrConfig { level: lvl, version: VersionMode::Auto, margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone() }; - let qr = QrCode::encode(&text, cfg).map_err(|e| E::new(e, 1))?; + let lvl: EcLevel = e + .level + .as_deref() + .map(|s| s.parse()) + .unwrap_or(Ok(EcLevel::M)) + .map_err(|e: String| anyhow::anyhow!(e))?; + let cfg = QrConfig { + level: lvl, + version: VersionMode::Auto, + margin: opts.margin, + }; + let qr = QrCode::encode(&text, cfg).map_err(|e| anyhow::anyhow!(e))?; let path = format!("{out}/qr_{:04}.png", i + 1); - std::fs::write(&path, qr.to_png_bytes(opts.size, None).map_err(|e| E::new(format!("{e}"), 2))?)?; + std::fs::write( + &path, + qr.to_png_bytes(opts.size, None) + .map_err(|e| anyhow::anyhow!("{e}"))?, + )?; pb.set_message(path.clone()); pb.inc(1); } @@ -256,55 +370,117 @@ fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> { Ok(()) } -fn batch_text(e: &BatchEntry) -> Result { - if let Some(c) = &e.content { return Ok(c.clone()); } - if let Some(u) = &e.url { return Ok(u.clone()); } +fn batch_text(e: &BatchEntry) -> Result { + if let Some(c) = &e.content { + return Ok(c.clone()); + } + if let Some(u) = &e.url { + return Ok(u.clone()); + } if let Some(s) = &e.ssid { - return Ok(text_builder::build_wifi_text(s, e.password.as_deref().unwrap_or(""), e.encryption.as_deref().unwrap_or("WPA"), e.hidden.unwrap_or(false))); + return Ok(text_builder::build_wifi_text( + s, + e.password.as_deref().unwrap_or(""), + e.encryption.as_deref().unwrap_or("WPA"), + e.hidden.unwrap_or(false), + )); } if let Some(n) = &e.name { - return Ok(text_builder::build_vcard_text(n, e.phone.as_deref().unwrap_or(""), e.email.as_deref().unwrap_or(""), e.company.as_deref().unwrap_or(""), e.address.as_deref().unwrap_or(""), "", "", "", "", "")); + return Ok(text_builder::build_vcard_text( + n, + e.phone.as_deref().unwrap_or(""), + e.email.as_deref().unwrap_or(""), + e.company.as_deref().unwrap_or(""), + e.address.as_deref().unwrap_or(""), + "", + "", + "", + "", + "", + )); } if let Some(t) = &e.to { - return Ok(text_builder::build_email_text(t, e.subject.as_deref().unwrap_or(""), e.body.as_deref().unwrap_or(""))); + return Ok(text_builder::build_email_text( + t, + e.subject.as_deref().unwrap_or(""), + e.body.as_deref().unwrap_or(""), + )); } if let Some(n) = &e.number { - if let Some(m) = &e.message { return Ok(text_builder::build_sms_text(n, m)); } + if let Some(m) = &e.message { + return Ok(text_builder::build_sms_text(n, m)); + } return Ok(text_builder::build_phone_text(n)); } - bail!("无法识别的条目格式", 1) + bail!("无法识别的条目格式") } // ──────────────────── 工具函数 ──────────────────── fn parse_csv(input: &str) -> Result, String> { let mut lines = input.lines(); - let cols: Vec<&str> = lines.next().ok_or("CSV 为空")?.split(',').map(|s| s.trim()).collect(); + let cols: Vec<&str> = lines + .next() + .ok_or("CSV 为空")? + .split(',') + .map(|s| s.trim()) + .collect(); let mut out = Vec::new(); for line in lines { - if line.trim().is_empty() { continue; } - let v: Vec = line.split(',').map(|s| s.trim().trim_matches('"').to_string()).collect(); - let (mut c,mut l,mut ss,mut pw,mut en,mut hi,mut na,mut ph,mut em,mut co,mut ad,mut to_,mut su,mut bo,mut nu,mut ms,mut ur) = - (None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None); - for (i, col) in cols.iter().enumerate() { - let val = v.get(i).cloned(); - match *col { "content"=>c=val,"level"=>l=val,"ssid"=>ss=val,"password"=>pw=val,"encryption"=>en=val,"hidden"=>hi=val.map(|x|x=="true"),"name"=>na=val,"phone"=>ph=val,"email"=>em=val,"company"=>co=val,"address"=>ad=val,"to"=>to_=val,"subject"=>su=val,"body"=>bo=val,"number"=>nu=val,"message"=>ms=val,"url"=>ur=val, _=>{} } + if line.trim().is_empty() { + continue; } - out.push(BatchEntry{content:c,level:l,ssid:ss,password:pw,encryption:en,hidden:hi,name:na,phone:ph,email:em,company:co,address:ad,to:to_,subject:su,body:bo,number:nu,message:ms,url:ur}); + let values: Vec = line + .split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .collect(); + + // 使用 HashMap 替代 17 字段元组解构 + let mut map = std::collections::HashMap::new(); + for (i, col) in cols.iter().enumerate() { + if let Some(val) = values.get(i).cloned() { + map.insert(*col, val); + } + } + + let get = |k: &str| map.get(k).cloned(); + let hidden = get("hidden").map(|x| x == "true"); + + out.push(BatchEntry { + content: get("content"), + level: get("level"), + ssid: get("ssid"), + password: get("password"), + encryption: get("encryption"), + hidden, + name: get("name"), + phone: get("phone"), + email: get("email"), + company: get("company"), + address: get("address"), + to: get("to"), + subject: get("subject"), + body: get("body"), + number: get("number"), + message: get("message"), + url: get("url"), + }); } Ok(out) } -fn parse_level(s: &str) -> Result { - match s.to_uppercase().as_str() { - "L"=>Ok(EcLevel::L),"M"=>Ok(EcLevel::M),"Q"=>Ok(EcLevel::Q),"H"=>Ok(EcLevel::H), - _ => bail!("无效纠错级别: '{s}',支持 L/M/Q/H", 1), +fn check_path(p: &str) -> Result<()> { + let path = Path::new(p); + // 禁止绝对路径 + if path.is_absolute() { + bail!("不允许使用绝对路径: {p}"); } -} - -fn check_path(p: &str) -> Result<(), E> { - if Path::new(p).components().any(|c| matches!(c, std::path::Component::ParentDir)) { - bail!("路径不允许包含 '..'", 2); + // 禁止父目录遍历 + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + bail!("路径不允许包含 '..': {p}"); } Ok(()) } diff --git a/core/Cargo.toml b/core/Cargo.toml index 5dfae9d..ea2e9ec 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,8 +14,10 @@ categories.workspace = true rust-version.workspace = true [dependencies] +encoding_rs = "0.8" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] } serde = { version = "1", features = ["derive"] } +thiserror = "2" [dev-dependencies] diff --git a/core/src/decoder/detect.rs b/core/src/decoder/detect.rs index 2494519..0bb1b9c 100644 --- a/core/src/decoder/detect.rs +++ b/core/src/decoder/detect.rs @@ -211,8 +211,11 @@ fn estimate_finder_size(gray: &[Vec], cx: usize, cy: usize) -> usize { } /// 从二值化图像中提取 QR 布尔矩阵 -pub(crate) fn detect_and_extract(gray: &[Vec]) -> Result { - let finders = find_finders(gray).ok_or("未找到 QR 码定位图案")?; +pub(crate) fn detect_and_extract( + gray: &[Vec], +) -> Result { + let finders = + find_finders(gray).ok_or_else(|| crate::error::QrError::DecodeFail("未找到 QR 码定位图案".into()))?; let tl = &finders[0]; // top-left let tr = &finders[1]; // top-right @@ -222,7 +225,9 @@ pub(crate) fn detect_and_extract(gray: &[Vec]) -> Result u16 { /// /// 从 2 处副本读取,各用 BCH 解码,取汉明距离更小者。 /// 如果两处都无法纠错,返回 Err。 -pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), String> { +pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), crate::error::QrError> { let raw1 = read_format_copy1(matrix); let raw2 = read_format_copy2(matrix); let dec1 = bch::decode_format_info(raw1); let dec2 = bch::decode_format_info(raw2); + let corrupt_err = + || crate::error::QrError::FormatCorrupted("无效纠错指示位".into()); + let decode_fail = + || crate::error::QrError::FormatCorrupted("格式信息解码失败:两处副本均无法纠错".into()); + // 偏好成功解码的结果 match (dec1, dec2) { - (Some((ec1, m1)), Some((ec2, m2))) if (ec1, m1) == (ec2, m2) => ec_from_bits(ec1) - .map(|lvl| (lvl, m1)) - .ok_or_else(|| "无效纠错指示位".into()), + (Some((ec1, m1)), Some((ec2, m2))) if (ec1, m1) == (ec2, m2) => { + ec_from_bits(ec1).map(|lvl| (lvl, m1)).ok_or_else(corrupt_err) + } (Some((ec1, m1)), Some((_, _))) => { // 两处不一致 — 偏好副本 1 ec_from_bits(ec1) .map(|lvl| (lvl, m1)) - .ok_or_else(|| "无效纠错指示位".into()) + .ok_or_else(corrupt_err) } (Some((ec, m)), None) | (None, Some((ec, m))) => ec_from_bits(ec) .map(|lvl| (lvl, m)) - .ok_or_else(|| "无效纠错指示位".into()), - (None, None) => Err("格式信息解码失败:两处副本均无法纠错".into()), + .ok_or_else(corrupt_err), + (None, None) => Err(decode_fail()), } } @@ -121,7 +126,7 @@ pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), String> /// /// 从 2 处副本读取,与格式信息策略相同。 /// 版本 < 7 时矩阵中无版本信息,此时应从尺寸反推。 -pub(crate) fn read_version_info(matrix: &Matrix) -> Result { +pub(crate) fn read_version_info(matrix: &Matrix) -> Result { let s = matrix.size as usize; if s < 45 { // 版本 1~6 无版本信息,从尺寸推算 @@ -129,7 +134,9 @@ pub(crate) fn read_version_info(matrix: &Matrix) -> Result { if (1..=6).contains(&ver) { return Ok(ver); } - return Err("无法从尺寸推算版本".into()); + return Err(crate::error::QrError::FormatCorrupted( + "无法从尺寸推算版本".into(), + )); } let raw1 = read_version_copy1(matrix); @@ -145,7 +152,9 @@ pub(crate) fn read_version_info(matrix: &Matrix) -> Result { Ok(v1) } (Some(v), None) | (None, Some(v)) => Ok(v), - (None, None) => Err("版本信息解码失败:两处副本均无法纠错".into()), + (None, None) => Err(crate::error::QrError::FormatCorrupted( + "版本信息解码失败:两处副本均无法纠错".into(), + )), } } @@ -186,10 +195,6 @@ fn read_version_copy2(matrix: &Matrix) -> u32 { #[cfg(test)] mod tests { use super::*; - use crate::matrix::grid::Matrix; - use crate::matrix::patterns::{ - encode_format_info, place_finder_patterns, place_format_info, place_timing_patterns, - }; use crate::qr::{QrCode, QrConfig}; #[test] diff --git a/core/src/decoder/image.rs b/core/src/decoder/image.rs index c4dd795..ea2fb38 100644 --- a/core/src/decoder/image.rs +++ b/core/src/decoder/image.rs @@ -5,8 +5,11 @@ /// 从图像字节加载并二值化 /// /// 步骤:解码 → 灰度 → 按中位数阈值二值化 -pub(crate) fn load_and_binarize(bytes: &[u8]) -> Result>, String> { - let img = image::load_from_memory(bytes).map_err(|e| format!("图像解码失败: {e}"))?; +pub(crate) fn load_and_binarize( + bytes: &[u8], +) -> Result>, crate::error::QrError> { + let img = + image::load_from_memory(bytes).map_err(|e| crate::error::QrError::DecodeFail(format!("图像解码失败: {e}")))?; let gray = img.to_luma8(); let (w, h) = gray.dimensions(); diff --git a/core/src/decoder/mod.rs b/core/src/decoder/mod.rs index dab78a9..e8e4501 100644 --- a/core/src/decoder/mod.rs +++ b/core/src/decoder/mod.rs @@ -22,6 +22,7 @@ mod mode_decode; mod perspective; mod rs_decode; +use crate::error::QrError; use crate::matrix::mask::apply_mask; use crate::version::{EcLevel, Version}; @@ -47,7 +48,7 @@ pub struct DecodeResult { /// /// # 返回 /// `DecodeResult` 包含解码文本和元信息 -pub fn decode_image(bytes: &[u8]) -> Result { +pub fn decode_image(bytes: &[u8]) -> Result { let gray = image::load_and_binarize(bytes)?; // 第一遍:直接检测 @@ -70,22 +71,25 @@ pub fn decode_image(bytes: &[u8]) -> Result { /// /// # 返回 /// `DecodeResult` 包含解码文本和元信息 -pub fn decode_matrix(matrix: &[Vec]) -> Result { +pub fn decode_matrix(matrix: &[Vec]) -> Result { // 1. 构建 Matrix 对象 let size = matrix.len() as u8; if matrix.is_empty() || matrix[0].is_empty() { - return Err("空矩阵".into()); + return Err(QrError::DecodeFail("空矩阵".into())); } // 验证方形 if matrix.iter().any(|r| r.len() != size as usize) { - return Err("矩阵不是方形".into()); + return Err(QrError::DecodeFail("矩阵不是方形".into())); } // 从尺寸推算版本 let version = ((size as i32 - 17) / 4) as u8; if !(1..=40).contains(&version) || (17 + version as i32 * 4) != size as i32 { - return Err(format!("无法从尺寸 {} 推算版本", size)); + return Err(QrError::DecodeFail(format!( + "无法从尺寸 {} 推算版本", + size + ))); } // 构建 Matrix 对象(简化:不预标注保留区域,BCH 读取函数直接访问坐标) @@ -106,7 +110,7 @@ pub fn decode_matrix(matrix: &[Vec]) -> Result { place_finder_patterns(&mut m); place_timing_patterns(&mut m); // 对齐图案位置依赖于版本,需要从版本查询 - let ver = Version::new(version).ok_or("无效版本号")?; + let ver = Version::new(version).ok_or(QrError::InvalidVersion(version))?; place_alignment_patterns(&mut m, ver.alignment_positions()); reserve_format_areas(&mut m); if version >= 7 { @@ -120,9 +124,9 @@ pub fn decode_matrix(matrix: &[Vec]) -> Result { if version >= 7 { let ver_info = format::read_version_info(&m)?; if ver_info != version { - return Err(format!( + return Err(QrError::DecodeFail(format!( "版本信息不匹配:尺寸估算 v{version},版本信息 v{ver_info}" - )); + ))); } } diff --git a/core/src/decoder/mode_decode.rs b/core/src/decoder/mode_decode.rs index 8417ce0..2f44c5a 100644 --- a/core/src/decoder/mode_decode.rs +++ b/core/src/decoder/mode_decode.rs @@ -2,7 +2,7 @@ //! //! 逆向 process: 读模式指示符(4-bit) → 读字符计数 → 按模式解码数据位 → 拼接文本 -use crate::encoder::mode::ALPHANUMERIC_CHARS; +use crate::encoder::mode::{Mode, ALPHANUMERIC_CHARS}; /// 从位向量读取 N 位,转为 u16(MSB 优先),自动推进位置 fn read_bits(bits: &[bool], pos: &mut usize, n: usize) -> u16 { @@ -17,37 +17,9 @@ fn read_bits(bits: &[bool], pos: &mut usize, n: usize) -> u16 { val } -/// 模式的字符计数位数(与 Mode::count_bits 一致) -fn char_count_bits(mode: u8, version: u8) -> u8 { - let ver = if version <= 9 { - 9 - } else if version <= 26 { - 26 - } else { - 40 - }; - match mode { - 0b0001 => match ver { - 9 => 10, - 26 => 12, - _ => 14, - }, // Numeric - 0b0010 => match ver { - 9 => 9, - 26 => 11, - _ => 13, - }, // Alphanumeric - 0b0100 => match ver { - 9 => 8, - _ => 16, - }, // Byte - 0b1000 => match ver { - 9 => 8, - 26 => 10, - _ => 12, - }, // Kanji - _ => 0, - } +/// 从 mode 指示符获取字符计数位宽(复用 encoder::Mode::count_bits) +fn char_count_bits(mode_indicator: u8, version: u8) -> Option { + Mode::from_indicator(mode_indicator).map(|m| m.count_bits(version)) } /// 数字模式解码 @@ -124,123 +96,45 @@ fn decode_kanji(bits: &[bool], pos: &mut usize, count: u16) -> String { /// 将 13-bit 的 Shift JIS 编码值转换回 Unicode 字符 /// -/// 逆向实现 `mode.rs::encode_kanji` 的逻辑: -/// 13-bit 值 → (hi_byte, lo_byte) → Unicode 码点 +/// 使用 `encoding_rs` 实现精确的 Shift JIS → Unicode 逆向映射。 fn shift_jis_value_to_char(val: u16) -> Option { - // 反推 Shift JIS 字节对 - // 高字节在 0x81..0x9F 时,值范围 0..0x1C6C (约 0xBC * 31) - // 高字节在 0xE0..0xEF 时,需要额外偏移 + // 从 13-bit 值反推 Shift JIS 字节对 + let hi: u8; + let lo: u8; - // Shift JIS → Unicode 查找表(覆盖常用 CJK 区域) - // 从 13-bit 值反推: - // 13-bit = (hi - 0x81) * 0xBC + (lo_offset) - // 如果 hi >= 0xE0: 13-bit += (0xC0 - 0x9F) * 0xBC - // lo_offset = 0 if lo in [0x40..0x7E], = (lo - 0x40) if in [0x80..0xFC] - - // 简化反推(与编码器的线性近似一致): - let val32 = val as u32; - - if val32 < 0x1C6C { + if val < 0x1C6C { // 高字节在 0x81..0x9F 范围 - let hi_off = val32 / 0xBC; - let lo_idx = val32 % 0xBC; - let hi = 0x81 + hi_off as u8; - let lo = if lo_idx < 0x3F { - 0x40 + lo_idx as u8 - } else { - 0x41 + lo_idx as u8 - }; - shift_jis_to_unicode(hi, lo) + let hi_off = (val / 0xBC) as u8; + let lo_idx = (val % 0xBC) as u8; + hi = 0x81 + hi_off; + lo = if lo_idx < 0x3F { 0x40 + lo_idx } else { 0x41 + lo_idx }; } else { // 高字节在 0xE0..0xEF 范围 - let offset = val32 - 0x1C6C; - let hi_off = 31 + offset / 0xBC; - let lo_idx = offset % 0xBC; - let hi = 0xE0 + (hi_off - 31) as u8; - let lo = if lo_idx < 0x3F { - 0x40 + lo_idx as u8 - } else { - 0x41 + lo_idx as u8 - }; - shift_jis_to_unicode(hi, lo) + let offset = val - 0x1C6C; + let hi_off = (offset / 0xBC) as u8; + let lo_idx = (offset % 0xBC) as u8; + hi = 0xE0 + hi_off; + lo = if lo_idx < 0x3F { 0x40 + lo_idx } else { 0x41 + lo_idx }; } -} -/// Shift JIS 字节对 → Unicode 码点 -fn shift_jis_to_unicode(hi: u8, lo: u8) -> Option { - // 标准 Shift JIS → Unicode 映射表(覆盖 BMP CJK) - // 简化版:处理常见区域 0x81-0x9F / 0xE0-0xEF - - if !is_valid_shift_jis(hi, lo) { + // 使用 encoding_rs 精确解码 Shift JIS → UTF-8 + let sjis_bytes = [hi, lo]; + let mut output = String::with_capacity(4); + let (result, _enc, _read) = + encoding_rs::SHIFT_JIS.new_decoder_without_bom_handling() + .decode_to_str_without_replacement(&sjis_bytes, &mut output, true); + use encoding_rs::DecoderResult; + if !matches!(result, DecoderResult::InputEmpty) || output.is_empty() { return None; } - - // 使用简化的偏移映射 - // 对于 0x81 区(JIS X 0208 行 1-62) - let hi_offset = if hi <= 0x9F { - (hi - 0x81) as u32 - } else { - (hi - 0xE0 + 31) as u32 - }; - - let lo_offset = if lo <= 0x7E { - (lo - 0x40) as u32 - } else { - (lo - 0x41) as u32 - }; - - if lo_offset >= 0xBC { - return None; - } - - // 简化 Unicode 码点计算(近似值,对应编码器的简化逻辑) - // 实际 QR 码标准使用 JIS X 0208 字符集 - let jis_row = hi_offset; // 0..93 - let jis_cell = lo_offset; // 0..187 - - // 简化的 JIS → Unicode 映射(覆盖常用字符) - jis_to_unicode(jis_row as u16, jis_cell as u16) -} - -fn is_valid_shift_jis(hi: u8, lo: u8) -> bool { - if !(0x81..=0xEF).contains(&hi) || hi == 0xA0 { - return false; - } - matches!(lo, 0x40..=0x7E | 0x80..=0xFC) -} - -/// JIS X 0208 行列 → Unicode(简化映射,覆盖 QR 汉字常用范围) -fn jis_to_unicode(row: u16, cell: u16) -> Option { - // 对偶数字节映射: 常见的 JIS 汉字区域映射到 Unicode CJK - // 这是简化映射,与编码器中的 unicode_to_shift_jis 的线性近似对应 - - if (0x21..=0x7E).contains(&row) { - // 非汉字区域(符号、数字、字母、假名) - // 简化的 Unicode 偏移 - if row <= 0x28 { - // 符号区 → Unicode 0x3000+ - let cp = 0x3000u32 + ((row - 0x21) as u32 * 0xBC + cell as u32); - char::from_u32(cp) - } else if row <= 0x2F { - // 数字/字母区 → Unicode 0xFF00+ - let cp = 0xFF00u32 + ((row - 0x29) as u32 * 0xBC + cell as u32); - char::from_u32(cp) - } else if row <= 0x51 { - // JIS 一级汉字 → Unicode CJK 0x4E00+ - let cp = 0x4E00u32 + ((row - 0x30) as u32 * 0xBC + cell as u32); - char::from_u32(cp) - } else { - // JIS 二级汉字 → Unicode CJK 0x8000+ - let cp = 0x8000u32 + ((row - 0x52) as u32 * 0xBC + cell as u32); - char::from_u32(cp) - } - } else { - None - } + output.chars().next() } /// 解码主函数:比特流 → 文本 -pub(crate) fn decode_bitstream(bits: &[bool], version: u8) -> Result { +pub(crate) fn decode_bitstream( + bits: &[bool], + version: u8, +) -> Result { let mut pos = 0; let mut text = String::new(); @@ -249,51 +143,38 @@ pub(crate) fn decode_bitstream(bits: &[bool], version: u8) -> Result bits.len() { + break; + } + let count = read_bits(bits, &mut pos, count_bits); match mode_indicator { - 0b0001 => { - // Numeric - let count_bits = char_count_bits(0b0001, version) as usize; - if pos + count_bits > bits.len() { - break; - } - let count = read_bits(bits, &mut pos, count_bits); - text.push_str(&decode_numeric(bits, &mut pos, count)); + 0b0001 => text.push_str(&decode_numeric(bits, &mut pos, count)), + 0b0010 => text.push_str(&decode_alphanumeric(bits, &mut pos, count)), + 0b0100 => text.push_str(&decode_byte(bits, &mut pos, count)), + 0b1000 => text.push_str(&decode_kanji(bits, &mut pos, count)), + _ => { + return Err(crate::error::QrError::DecodeFail(format!( + "未知模式指示符: {:04b}", + mode_indicator + ))) } - 0b0010 => { - // Alphanumeric - let count_bits = char_count_bits(0b0010, version) as usize; - if pos + count_bits > bits.len() { - break; - } - let count = read_bits(bits, &mut pos, count_bits); - text.push_str(&decode_alphanumeric(bits, &mut pos, count)); - } - 0b0100 => { - // Byte - let count_bits = char_count_bits(0b0100, version) as usize; - if pos + count_bits > bits.len() { - break; - } - let count = read_bits(bits, &mut pos, count_bits); - text.push_str(&decode_byte(bits, &mut pos, count)); - } - 0b1000 => { - // Kanji - let count_bits = char_count_bits(0b1000, version) as usize; - if pos + count_bits > bits.len() { - break; - } - let count = read_bits(bits, &mut pos, count_bits); - text.push_str(&decode_kanji(bits, &mut pos, count)); - } - 0b0000 => break, // 终止符 - _ => return Err(format!("未知模式指示符: {:04b}", mode_indicator)), } } if text.is_empty() { - Err("未解码到任何文本".into()) + Err(crate::error::QrError::DecodeFail("未解码到任何文本".into())) } else { Ok(text) } diff --git a/core/src/decoder/rs_decode.rs b/core/src/decoder/rs_decode.rs index 442aabc..39a38b7 100644 --- a/core/src/decoder/rs_decode.rs +++ b/core/src/decoder/rs_decode.rs @@ -15,7 +15,10 @@ use crate::ecc::galois; /// /// # 错误 /// 如果错误数超过 `ec_count / 2`,返回 Err -pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec, usize), String> { +pub(crate) fn rs_correct( + data: &[u8], + ec: &[u8], +) -> Result<(Vec, usize), crate::error::QrError> { let ec_count = ec.len(); let n = data.len() + ec_count; @@ -37,7 +40,9 @@ pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec, usize), Str let error_positions = chien_search(&lambda, n)?; if error_positions.is_empty() { - return Err("检测到错误但无法定位".into()); + return Err(crate::error::QrError::DecodeFail( + "检测到错误但无法定位".into(), + )); } // 4. Forney 算法求错误幅值 @@ -54,7 +59,9 @@ pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec, usize), Str // 6. 验证 let verify_syn = compute_syndromes(&corrected, ec_count); if verify_syn.iter().any(|&s| s != 0) { - return Err("纠错失败:验证未通过".into()); + return Err(crate::error::QrError::DecodeFail( + "纠错失败:验证未通过".into(), + )); } Ok((corrected[..data.len()].to_vec(), errors_corrected)) @@ -81,7 +88,10 @@ fn compute_syndromes(received: &[u8], ec_count: usize) -> Vec { /// Berlekamp-Massey 算法 — 寻找错误位置多项式 Λ(x) /// /// 返回 Λ 的系数向量(低次到高次),Λ[0] = 1 -fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result, String> { +fn berlekamp_massey( + syndromes: &[u8], + ec_count: usize, +) -> Result, crate::error::QrError> { let t = ec_count; let mut lambda = vec![1u8]; // Λ(x) = 1 let mut b = vec![1u8]; // B(x) = 1 @@ -123,7 +133,8 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result, String if 2 * l <= r { // B(x) = Λ(x) / δ b = lambda.clone(); - let delta_inv = galois::div(1, delta).ok_or("除法错误")?; + let delta_inv = galois::div(1, delta) + .ok_or_else(|| crate::error::QrError::Internal("除法错误".into()))?; for coeff in &mut b { *coeff = galois::mul(*coeff, delta_inv); } @@ -137,12 +148,16 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result, String } if l > t { - return Err("错误数超出纠错能力".into()); + return Err(crate::error::QrError::DecodeFail( + "错误数超出纠错能力".into(), + )); } } if l == 0 { - return Err("无错误(BM 算法异常)".into()); + return Err(crate::error::QrError::DecodeFail( + "无错误(BM 算法异常)".into(), + )); } // Strip trailing zeros @@ -158,7 +173,7 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result, String /// 遍历 GF(2⁸) 所有非零元素 α^i,检查 Λ(α^i) == 0 /// 若 i 为根,则错误多项式指数 k = -i mod 255 = (255-i)%255 /// 码字数组中对应位置 = n-1-k -fn chien_search(lambda: &[u8], n: usize) -> Result, String> { +fn chien_search(lambda: &[u8], n: usize) -> Result, crate::error::QrError> { let mut positions = Vec::new(); // 搜索所有可能的根 α^i for i=0..254 @@ -177,7 +192,9 @@ fn chien_search(lambda: &[u8], n: usize) -> Result, String> { } if positions.is_empty() { - Err("Chien 搜索无结果".into()) + Err(crate::error::QrError::DecodeFail( + "Chien 搜索无结果".into(), + )) } else { Ok(positions) } diff --git a/core/src/encoder/mode.rs b/core/src/encoder/mode.rs index ebfcb4f..fe4ac65 100644 --- a/core/src/encoder/mode.rs +++ b/core/src/encoder/mode.rs @@ -18,6 +18,17 @@ impl Mode { } } + /// 从 4-bit 模式指示符还原 Mode + pub fn from_indicator(ind: u8) -> Option { + match ind { + 0b0001 => Some(Mode::Numeric), + 0b0010 => Some(Mode::Alphanumeric), + 0b0100 => Some(Mode::Byte), + 0b1000 => Some(Mode::Kanji), + _ => None, + } + } + /// 字符计数指示符长度(bit),取决于版本号 pub fn count_bits(self, version: u8) -> u8 { match self { @@ -139,9 +150,8 @@ pub fn encode_byte(input: &str) -> Vec { /// 汉字模式编码 (Shift JIS → 13 bit) /// 对于无法转换为 Shift JIS 的字符,使用全零占位符(避免段内模式混用) /// -/// 注: segment_text 保证 Kanji 段内字符均通过 is_kanji() 检测, -/// 但 unicode_to_shift_jis 目前仅覆盖 U+4E00-U+9FFF (基本 CJK 统一汉字)。 -/// U+3400-U+4DBF (CJK Ext-A) 和 U+3000-U+303F (CJK 符号) 的映射有待补全。 +/// 使用 `encoding_rs` 实现完整的 JIS X 0208 ↔ Unicode 双向映射, +/// 覆盖所有标准 Shift JIS 字符(CJK、假名、符号等)。 pub fn encode_kanji(input: &str) -> Vec { let mut bits = Vec::new(); for c in input.chars() { @@ -158,54 +168,49 @@ pub fn encode_kanji(input: &str) -> Vec { bits } -/// Unicode → Shift JIS 简化转换 -/// 覆盖常用 CJK 统一汉字 (U+4E00 ~ U+9FFF) +/// Unicode → Shift JIS → 13-bit QR 码字(使用 encoding_rs 精确映射) /// -/// 注意: 此映射采用线性近似公式。实际上 Unicode CJK 与 Shift JIS (JIS X 0208) -/// 并非严格线性对应,对于非线性的字符会产生偏差。 -/// 对于需要精确映射的场景,建议使用完整的 CJK→Shift JIS 映射表。 +/// encoding_rs 提供完整的 JIS X 0208 ↔ Unicode 双向映射,符合 ISO/IEC 18004 标准。 fn unicode_to_shift_jis(c: char) -> Option { - let code = c as u32; - // CJK 统一汉字 基本区 - if (0x4E00..=0x9FFF).contains(&code) { - let base = code - 0x4E00; - - // 偏移分片: 高字节每 0xBC 个字符换一行 - let hi_offset = base / 0xBC; - let lo_offset = base % 0xBC; - - // Shift JIS 汉字有两段区间: 0x81-0x9F 和 0xE0-0xEF - // 中间 0xA0-0xDF 为间隙,需要跳过 - let hi = if hi_offset < 31 { - 0x81u16 + hi_offset as u16 - } else { - 0xE0u16 + (hi_offset - 31) as u16 - }; - - // 第二字节有效范围: 0x40-0x7E 和 0x80-0xFC (跳过 0x7F) - let lo_base = 0x40u16 + lo_offset as u16; - let lo = if lo_base >= 0x7F { - lo_base + 1 // 跳过无效的 0x7F - } else { - lo_base - }; - - // 行内索引: 0x40..=0x7E → 0..62, 0x80..=0xFC → 63..187 - let lo_idx = if lo >= 0x80 { - lo - 0x41 // 跳过 0x7F 后的偏移 - } else { - lo - 0x40 - }; - - // 映射到 13-bit 码字 - let val = if (0x81..=0x9Fu16).contains(&hi) { - (hi - 0x81) * 0xBC + lo_idx - } else { - (hi - 0xC1) * 0xBC + lo_idx - }; - return Some(val); + // 将单个字符编码为 Shift JIS 字节 + let mut utf8_buf = [0u8; 4]; + let s = c.encode_utf8(&mut utf8_buf); + let mut sjis_buf = [0u8; 4]; + let (result, _enc, _read) = + encoding_rs::SHIFT_JIS.new_encoder().encode_from_utf8_without_replacement( + s, + &mut sjis_buf, + true, + ); + // 如果编码器报告了错误,说明有无法映射的字符 + use encoding_rs::EncoderResult; + if !matches!(result, EncoderResult::InputEmpty) { + return None; } - None + + let sjis_bytes = &sjis_buf[..2]; + let hi = sjis_bytes[0] as u16; + let lo = sjis_bytes[1] as u16; + + // 验证有效 Shift JIS 范围 + if !(0x81..=0xEF).contains(&hi) || hi == 0xA0 { + return None; + } + if !(0x40..=0x7E).contains(&lo) && !(0x80..=0xFC).contains(&lo) { + return None; + } + + // 行内索引(与 QR 码标准一致) + let lo_idx = if lo >= 0x80 { lo - 0x41 } else { lo - 0x40 }; + + // 映射到 13-bit 码字 + let val = if (0x81..=0x9F).contains(&hi) { + (hi - 0x81) * 0xBC + lo_idx + } else { + 0x1C6C + (hi - 0xE0) * 0xBC + lo_idx + }; + + Some(val) } /// 判断字符是否属于数字模式 diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..963213f --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,56 @@ +//! QR 码错误类型 +//! +//! 为 `qr-core` 库提供类型化的错误枚举,替代裸 `String` 错误。 + +use std::fmt; + +/// QR 码编解码错误 +#[derive(Debug)] +pub enum QrError { + /// 输入为空 + EmptyInput, + /// 无效版本号 (1-40) + InvalidVersion(u8), + /// 数据过长,超出 QR 码最大容量 + DataTooLong, + /// 解码失败 + DecodeFail(String), + /// 格式/版本信息损坏 + FormatCorrupted(String), + /// 颜色格式错误 + InvalidColor(String), + /// 图像 I/O 错误 + Image(image::ImageError), + /// 内部错误 + Internal(String), +} + +impl fmt::Display for QrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + QrError::EmptyInput => write!(f, "输入为空"), + QrError::InvalidVersion(v) => write!(f, "无效版本号 (1-40): {v}"), + QrError::DataTooLong => write!(f, "数据过长,超出 QR 码最大容量"), + QrError::DecodeFail(msg) => write!(f, "解码失败: {msg}"), + QrError::FormatCorrupted(msg) => write!(f, "格式信息损坏: {msg}"), + QrError::InvalidColor(msg) => write!(f, "颜色格式错误: {msg}"), + QrError::Image(e) => write!(f, "图像错误: {e}"), + QrError::Internal(msg) => write!(f, "内部错误: {msg}"), + } + } +} + +impl std::error::Error for QrError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + QrError::Image(e) => Some(e), + _ => None, + } + } +} + +impl From for QrError { + fn from(e: image::ImageError) -> Self { + QrError::Image(e) + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index b0f94e5..1cca66d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,8 +1,11 @@ pub mod decoder; pub mod ecc; pub mod encoder; +pub mod error; pub mod matrix; pub mod qr; pub mod render; pub mod text_builder; pub mod version; + +pub use error::QrError; diff --git a/core/src/matrix/mask.rs b/core/src/matrix/mask.rs index e272600..5b49542 100644 --- a/core/src/matrix/mask.rs +++ b/core/src/matrix/mask.rs @@ -15,6 +15,31 @@ pub const MASK_FNS: [MaskFn; 8] = [ |x, y| ((x as u32 + y as u32) % 2 + (x as u32 * y as u32) % 3).is_multiple_of(2), ]; +/// 掩码视图 — 懒计算掩码结果,避免克隆整个 Matrix +struct MaskedView<'a> { + matrix: &'a Matrix, + mask_fn: MaskFn, +} + +impl<'a> MaskedView<'a> { + fn new(matrix: &'a Matrix, mask_idx: u8) -> Self { + Self { matrix, mask_fn: MASK_FNS[mask_idx as usize] } + } + + fn get(&self, x: u8, y: u8) -> bool { + let val = self.matrix.get(x, y); + if self.matrix.is_reserved(x, y) { + val + } else { + val ^ (self.mask_fn)(x, y) + } + } + + fn size(&self) -> u8 { + self.matrix.size + } +} + /// 应用掩码到矩阵的数据区域(跳过功能图案保留区域) pub fn apply_mask(matrix: &Matrix, mask_idx: u8) -> Matrix { let mask_fn = MASK_FNS[mask_idx as usize]; @@ -30,22 +55,29 @@ pub fn apply_mask(matrix: &Matrix, mask_idx: u8) -> Matrix { result } -/// 惩罚评分(越低越好) +/// 惩罚评分(越低越好)— 对 MaskedView 评分,避免 Matrix 克隆 +fn score_view(view: &MaskedView) -> u32 { + score_rule1(view) + score_rule2(view) + score_rule3(view) + score_rule4(view) +} + +/// 惩罚评分(原始 Matrix,用于测试兼容) pub fn score(matrix: &Matrix) -> u32 { - score_rule1(matrix) + score_rule2(matrix) + score_rule3(matrix) + score_rule4(matrix) + // 使用 mask 0 的视图(无掩码时等价于原始矩阵的非保留区域值) + // 通过 identity view 实现 + score_rule1_raw(matrix) + score_rule2_raw(matrix) + score_rule3_raw(matrix) + score_rule4_raw(matrix) } /// 规则 1: 连续 5+ 同色行/列 → N1 + k - 5 -fn score_rule1(matrix: &Matrix) -> u32 { +fn score_rule1(view: &MaskedView) -> u32 { let mut penalty = 0u32; - let n = matrix.size as usize; + let n = view.size() as usize; // 水平扫描 for y in 0..n { let mut run = 1u32; - let mut prev = matrix.get(0, y as u8); + let mut prev = view.get(0, y as u8); for x in 1..n { - let cur = matrix.get(x as u8, y as u8); + let cur = view.get(x as u8, y as u8); if cur == prev { run += 1; } else { @@ -64,9 +96,9 @@ fn score_rule1(matrix: &Matrix) -> u32 { // 垂直扫描 for x in 0..n { let mut run = 1u32; - let mut prev = matrix.get(x as u8, 0); + let mut prev = view.get(x as u8, 0); for y in 1..n { - let cur = matrix.get(x as u8, y as u8); + let cur = view.get(x as u8, y as u8); if cur == prev { run += 1; } else { @@ -85,8 +117,57 @@ fn score_rule1(matrix: &Matrix) -> u32 { penalty } +/// 原始 Matrix 版规则 1(用于 score() 公共 API) +fn score_rule1_raw(matrix: &Matrix) -> u32 { + let mut penalty = 0u32; + let n = matrix.size as usize; + + for y in 0..n { + let mut run = 1u32; + let mut prev = matrix.get(0, y as u8); + for x in 1..n { + let cur = matrix.get(x as u8, y as u8); + if cur == prev { run += 1; } else { + if run >= 5 { penalty += 3 + run - 5; } + run = 1; prev = cur; + } + } + if run >= 5 { penalty += 3 + run - 5; } + } + for x in 0..n { + let mut run = 1u32; + let mut prev = matrix.get(x as u8, 0); + for y in 1..n { + let cur = matrix.get(x as u8, y as u8); + if cur == prev { run += 1; } else { + if run >= 5 { penalty += 3 + run - 5; } + run = 1; prev = cur; + } + } + if run >= 5 { penalty += 3 + run - 5; } + } + penalty +} + /// 规则 2: 同色 2×2 方块,每个 +3 -fn score_rule2(matrix: &Matrix) -> u32 { +fn score_rule2(view: &MaskedView) -> u32 { + let mut count = 0u32; + let n = view.size(); + for y in 0..n - 1 { + for x in 0..n - 1 { + let v = view.get(x, y); + if view.get(x + 1, y) == v + && view.get(x, y + 1) == v + && view.get(x + 1, y + 1) == v + { + count += 1; + } + } + } + count * 3 +} + +fn score_rule2_raw(matrix: &Matrix) -> u32 { let mut count = 0u32; let n = matrix.size; for y in 0..n - 1 { @@ -104,63 +185,61 @@ fn score_rule2(matrix: &Matrix) -> u32 { } /// 规则 3: 检测 1011101 模式(及其反转),每次 +40 -fn score_rule3(matrix: &Matrix) -> u32 { +fn score_rule3(view: &MaskedView) -> u32 { let mut penalty = 0u32; - let n = matrix.size as usize; + let n = view.size() as usize; + + const PAT_FWD: [bool; 11] = [true, false, true, true, true, false, true, false, false, false, false]; + const PAT_REV: [bool; 11] = [false, false, false, false, true, false, true, true, true, false, true]; - // 水平方向 for y in 0..n { - for x in 0..n { - if x + 10 >= n { - continue; - } - // 正模式: 10111010000 - let forward = (0..11).all(|i| { - let expected = [ - true, false, true, true, true, false, true, false, false, false, false, - ][i]; - matrix.get((x + i) as u8, y as u8) == expected - }); - if forward { - penalty += 40; - } - - // 反模式: 00001011101 - let reverse = (0..11).all(|i| { - let expected = [ - false, false, false, false, true, false, true, true, true, false, true, - ][i]; - matrix.get((x + i) as u8, y as u8) == expected - }); - if reverse { + for x in 0..n.saturating_sub(10) { + if (0..11).all(|i| view.get((x + i) as u8, y as u8) == PAT_FWD[i]) + || (0..11).all(|i| view.get((x + i) as u8, y as u8) == PAT_REV[i]) + { penalty += 40; } } } - // 垂直方向 + for y in 0..n.saturating_sub(10) { + for x in 0..n { + if (0..11).all(|i| view.get(x as u8, (y + i) as u8) == PAT_FWD[i]) + || (0..11).all(|i| view.get(x as u8, (y + i) as u8) == PAT_REV[i]) + { + penalty += 40; + } + } + } + + penalty +} + +fn score_rule3_raw(matrix: &Matrix) -> u32 { + let mut penalty = 0u32; + let n = matrix.size as usize; + + const PAT_FWD: [bool; 11] = [true, false, true, true, true, false, true, false, false, false, false]; + const PAT_REV: [bool; 11] = [false, false, false, false, true, false, true, true, true, false, true]; + + for y in 0..n { + for x in 0..n { + if x + 10 < n + && ((0..11).all(|i| matrix.get((x + i) as u8, y as u8) == PAT_FWD[i]) + || (0..11).all(|i| matrix.get((x + i) as u8, y as u8) == PAT_REV[i])) + { + penalty += 40; + } + } + } for y in 0..n { if y + 10 >= n { continue; } for x in 0..n { - let forward = (0..11).all(|i| { - let expected = [ - true, false, true, true, true, false, true, false, false, false, false, - ][i]; - matrix.get(x as u8, (y + i) as u8) == expected - }); - if forward { - penalty += 40; - } - - let reverse = (0..11).all(|i| { - let expected = [ - false, false, false, false, true, false, true, true, true, false, true, - ][i]; - matrix.get(x as u8, (y + i) as u8) == expected - }); - if reverse { + if (0..11).all(|i| matrix.get(x as u8, (y + i) as u8) == PAT_FWD[i]) + || (0..11).all(|i| matrix.get(x as u8, (y + i) as u8) == PAT_REV[i]) + { penalty += 40; } } @@ -170,37 +249,45 @@ fn score_rule3(matrix: &Matrix) -> u32 { } /// 规则 4: 暗模块占比偏离 50%,每 5% +10 -fn score_rule4(matrix: &Matrix) -> u32 { +fn score_rule4(view: &MaskedView) -> u32 { + let total = (view.size() as u32) * (view.size() as u32); + let dark: u32 = (0..view.size()) + .flat_map(|y| (0..view.size()).map(move |x| view.get(x, y) as u32)) + .sum(); + + let pct = (dark * 100 + total / 2) / total; + let deviation = ((pct as i32 - 50).unsigned_abs()) / 5; + deviation * 10 +} + +fn score_rule4_raw(matrix: &Matrix) -> u32 { let total = (matrix.size as u32) * (matrix.size as u32); let dark: u32 = (0..matrix.size) .flat_map(|y| (0..matrix.size).map(move |x| matrix.get(x, y) as u32)) .sum(); - - let pct = (dark * 100 + total / 2) / total; // 四舍五入 + let pct = (dark * 100 + total / 2) / total; let deviation = ((pct as i32 - 50).unsigned_abs()) / 5; deviation * 10 } /// 评估所有 8 种掩码,返回最佳掩码编号和对应矩阵 +/// +/// 优化:使用 MaskedView 懒计算,避免每种掩码克隆整个 Matrix(版本 40 可节省 ~248KB 临时分配) pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) { let mut best_idx = 0u8; let mut best_score = u32::MAX; - let mut best_matrix: Option = None; for i in 0..8u8 { - let masked = apply_mask(matrix, i); - let s = score(&masked); - if best_matrix.is_none() || s < best_score { + let view = MaskedView::new(matrix, i); + let s = score_view(&view); + if s < best_score { best_score = s; best_idx = i; - best_matrix = Some(masked); } } - ( - best_idx, - best_matrix.expect("掩码循环 (0..8) 至少执行一次,best_matrix 必定有值"), - ) + // 只对最佳掩码应用一次克隆 + (best_idx, apply_mask(matrix, best_idx)) } #[cfg(test)] @@ -210,9 +297,7 @@ mod tests { #[test] fn test_apply_mask() { let m = Matrix::new(21); - // 不设置 reserved,所有区域都是数据区 let masked = apply_mask(&m, 0); // (x+y) % 2 == 0 - // 初始全白,掩码 0 会在 (x+y)%2==0 的位置翻转 assert_eq!(masked.get(0, 0), true); // (0+0)%2=0 → 翻转 assert_eq!(masked.get(1, 0), false); // (1+0)%2=1 → 不变 } @@ -220,27 +305,24 @@ mod tests { #[test] fn test_score_rule2() { let mut m = Matrix::new(3); - // 全黑 → 4 个 2×2 方块 for y in 0..3u8 { for x in 0..3u8 { m.set(x, y, true); } } - assert_eq!(score_rule2(&m), 4 * 3); // 4 blocks × 3 = 12 + let view = MaskedView::new(&m, 0); + assert_eq!(score_rule2(&view), 4 * 3); } #[test] fn test_score_rule4() { let m = Matrix::new(10); - // 全部白色 → 0% dark → 偏离 50% = 10 × 5% → penalty = 10 × 10 = 100 - let s = score_rule4(&m); - assert_eq!(s, 100); + assert_eq!(score_rule4_raw(&m), 100); } #[test] fn test_best_mask_selects_something() { let mut m = Matrix::new(21); - // 填一些随机数据 for y in 0..21u8 { for x in 0..21u8 { m.set(x, y, (x as u32 * y as u32) % 3 == 0); @@ -249,4 +331,14 @@ mod tests { let (idx, _masked) = best_mask(&m); assert!(idx < 8); } + + #[test] + fn test_score_roundtrip_masked_equals_original_for_mask0_on_empty() { + // 验证 score(Matrix) 和 score_view(MaskedView) 在无保留区时一致 + let m = Matrix::new(21); + let view = MaskedView::new(&m, 0); // mask 0 flips (x+y)%2==0 + let s_view = score_view(&view); + let s_apply = score(&apply_mask(&m, 0)); + assert_eq!(s_view, s_apply, "懒评分与直接评分应一致"); + } } diff --git a/core/src/qr.rs b/core/src/qr.rs index c05a697..059c488 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -1,6 +1,7 @@ use crate::ecc::reed_solomon; use crate::encoder::bitstream::build_codewords; use crate::encoder::segment::{segment_bit_length, segment_text}; +use crate::error::QrError; use crate::matrix::grid::Matrix; use crate::matrix::mask::best_mask; use crate::matrix::patterns::{ @@ -63,21 +64,15 @@ pub struct QrConfig { pub version: VersionMode, /// 静区边距(模块数),默认 4 pub margin: u8, - /// 前景色(CSS 格式 "#RRGGBB"),默认 "#000000" - pub fg_color: Option, - /// 背景色(CSS 格式 "#RRGGBB"),默认 "#FFFFFF" - pub bg_color: Option, } impl Default for QrConfig { - /// 默认配置:M 级纠错 + 自动版本 + 4 模块边距 + 黑前景白背景 + /// 默认配置:M 级纠错 + 自动版本 + 4 模块边距 fn default() -> Self { QrConfig { level: EcLevel::M, version: VersionMode::Auto, margin: 4, - fg_color: None, - bg_color: None, } } } @@ -114,10 +109,6 @@ pub struct QrCode { matrix: Matrix, /// 静区边距(模块数) pub margin: u8, - /// 前景色 RGB [r, g, b] - pub fg_color: [u8; 3], - /// 背景色 RGB [r, g, b] - pub bg_color: [u8; 3], } impl QrCode { @@ -142,16 +133,16 @@ impl QrCode { /// assert_eq!(qr.version.0, 1); /// assert_eq!(qr.size(), 21); /// ``` - pub fn encode(text: &str, config: QrConfig) -> Result { + pub fn encode(text: &str, config: QrConfig) -> Result { // 1. 分段 let segments = segment_text(text); if segments.is_empty() { - return Err("输入为空".into()); + return Err(QrError::EmptyInput); } // 2. 确定版本 let version = match config.version { - VersionMode::Fixed(v) => Version::new(v).ok_or("无效版本号 (1-40)")?, + VersionMode::Fixed(v) => Version::new(v).ok_or(QrError::InvalidVersion(v))?, VersionMode::Auto => { let mut selected = None; for v in 1..=40 { @@ -163,7 +154,7 @@ impl QrCode { break; } } - selected.ok_or("数据过长,超出 QR 码最大容量".to_string())? + selected.ok_or(QrError::DataTooLong)? } }; @@ -178,7 +169,7 @@ impl QrCode { for _ in 0..binfo.count { let end = pos + binfo.data_codewords as usize; if end > data.len() { - return Err("内部错误: 数据码字不足".into()); + return Err(QrError::Internal("数据码字不足".into())); } blocks.push(data[pos..end].to_vec()); pos = end; @@ -213,17 +204,12 @@ impl QrCode { place_version_info(&mut final_matrix, ver_info); } - let fg_color = parse_hex_color(config.fg_color.as_deref().unwrap_or("#000000"))?; - let bg_color = parse_hex_color(config.bg_color.as_deref().unwrap_or("#FFFFFF"))?; - Ok(QrCode { version, level: config.level, mask: best_idx, matrix: final_matrix, margin: config.margin, - fg_color, - bg_color, }) } @@ -249,18 +235,25 @@ impl QrCode { /// 导出为 SVG 字符串 /// - /// SVG 内含 `viewBox`、使用 QrCode 的前/背景色。 /// `logo` 可选的 logo 图片字节,会以 base64 嵌入 SVG 中央。 + /// `fg` 前景色("#RRGGBB"),默认 "#000000"。 + /// `bg` 背景色("#RRGGBB"),默认 "#FFFFFF"。 /// /// ```rust /// use qr_core::qr::{QrCode, QrConfig}; /// /// let qr = QrCode::encode("test", QrConfig::default()).unwrap(); - /// let svg = qr.to_svg(None); + /// let svg = qr.to_svg(None, None, None); /// assert!(svg.starts_with(") -> String { - crate::render::svg::render_svg(self, logo) + pub fn to_svg(&self, logo: Option<&[u8]>, fg: Option<&str>, bg: Option<&str>) -> String { + let fg_color = fg + .and_then(|c| parse_hex_color(c).ok()) + .unwrap_or([0, 0, 0]); + let bg_color = bg + .and_then(|c| parse_hex_color(c).ok()) + .unwrap_or([255, 255, 255]); + crate::render::svg::render_svg(self, logo, &fg_color, &bg_color) } /// 导出为终端 ASCII 文本 @@ -276,24 +269,35 @@ impl QrCode { /// `module_size` 控制每个模块的像素大小(2~20)。 /// `format` 输出格式,默认为 Png。 /// `logo` 可选的 logo 图片字节。 + /// `fg` 前景色,默认 "#000000"。`bg` 背景色,默认 "#FFFFFF"。 /// /// ```rust /// use qr_core::qr::{QrCode, QrConfig}; /// /// let qr = QrCode::encode("test", QrConfig::default()).unwrap(); - /// let bytes = qr.to_image_bytes(4, None, None).unwrap(); + /// let bytes = qr.to_image_bytes(4, None, None, None, None).unwrap(); /// ``` pub fn to_image_bytes( &self, module_size: u8, logo: Option<&[u8]>, format: Option, - ) -> Result, image::ImageError> { + fg: Option<&str>, + bg: Option<&str>, + ) -> Result, QrError> { + let fg_color = fg + .and_then(|c| parse_hex_color(c).ok()) + .unwrap_or([0, 0, 0]); + let bg_color = bg + .and_then(|c| parse_hex_color(c).ok()) + .unwrap_or([255, 255, 255]); crate::render::image::render_image( self, module_size, format.unwrap_or(crate::render::image::OutputFormat::Png), logo, + &fg_color, + &bg_color, ) } @@ -302,8 +306,8 @@ impl QrCode { &self, module_size: u8, logo: Option<&[u8]>, - ) -> Result, image::ImageError> { - self.to_image_bytes(module_size, logo, None) + ) -> Result, QrError> { + self.to_image_bytes(module_size, logo, None, None, None) } } @@ -311,32 +315,41 @@ impl QrCode { /// /// 支持格式: "#RGB", "#RRGGBB" /// 无效格式返回 Err -fn parse_hex_color(s: &str) -> Result<[u8; 3], String> { +pub(crate) fn parse_hex_color(s: &str) -> Result<[u8; 3], QrError> { let s = s.trim(); if !s.starts_with('#') { - return Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)); + return Err(QrError::InvalidColor(s.to_string())); } let hex = &s[1..]; match hex.len() { 3 => { - let r = u8::from_str_radix(&hex[0..1].repeat(2), 16) - .map_err(|_| format!("无效颜色值: '{}'", s))?; - let g = u8::from_str_radix(&hex[1..2].repeat(2), 16) - .map_err(|_| format!("无效颜色值: '{}'", s))?; - let b = u8::from_str_radix(&hex[2..3].repeat(2), 16) - .map_err(|_| format!("无效颜色值: '{}'", s))?; + // #RGB → 每个分量乘以 17 扩展为 #RRGGBB(避免 String 分配) + // SAFETY: hex 来自 `s[1..]`,已通过 starts_with('#') 检查,且只含 ASCII hex 字符 + let bytes = hex.as_bytes(); + let r = u8::from_str_radix(unsafe { + std::str::from_utf8_unchecked(&[bytes[0], bytes[0]]) + }, 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; + let g = u8::from_str_radix(unsafe { + std::str::from_utf8_unchecked(&[bytes[1], bytes[1]]) + }, 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; + let b = u8::from_str_radix(unsafe { + std::str::from_utf8_unchecked(&[bytes[2], bytes[2]]) + }, 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; Ok([r, g, b]) } 6 => { - let r = - u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("无效颜色值: '{}'", s))?; - let g = - u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("无效颜色值: '{}'", s))?; - let b = - u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("无效颜色值: '{}'", s))?; + let r = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; + let g = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; + let b = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| QrError::InvalidColor(s.to_string()))?; Ok([r, g, b]) } - _ => Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)), + _ => Err(QrError::InvalidColor(s.to_string())), } } @@ -396,15 +409,8 @@ mod tests { #[test] fn test_color_qr() { - let config = QrConfig { - fg_color: Some("#FF0000".into()), - bg_color: Some("#0000FF".into()), - ..Default::default() - }; - let qr = QrCode::encode("COLOR TEST", config).unwrap(); - assert_eq!(qr.fg_color, [255, 0, 0]); - assert_eq!(qr.bg_color, [0, 0, 255]); - let svg = qr.to_svg(None); + let qr = QrCode::encode("COLOR TEST", QrConfig::default()).unwrap(); + let svg = qr.to_svg(None, Some("#FF0000"), Some("#0000FF")); assert!(svg.contains("#FF0000")); assert!(svg.contains("#0000FF")); } diff --git a/core/src/render/image.rs b/core/src/render/image.rs index 2a65968..45f1371 100644 --- a/core/src/render/image.rs +++ b/core/src/render/image.rs @@ -80,8 +80,8 @@ fn fill_module( } } -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}"))?; +fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), crate::error::QrError> { + let logo = image::load_from_memory(logo_bytes).map_err(crate::error::QrError::Image)?; let logo = logo.to_rgba8(); let img_w = img.width(); let img_h = img.height(); @@ -102,7 +102,9 @@ pub fn render_image( module_size: u8, format: OutputFormat, logo: Option<&[u8]>, -) -> Result, image::ImageError> { + fg: &[u8; 3], + bg: &[u8; 3], +) -> Result, crate::error::QrError> { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; let total_size = matrix_size + 2 * margin; @@ -123,15 +125,7 @@ pub fn render_image( } else { 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, fg, bg); } } diff --git a/core/src/render/svg.rs b/core/src/render/svg.rs index ad1fd92..b67d649 100644 --- a/core/src/render/svg.rs +++ b/core/src/render/svg.rs @@ -1,18 +1,12 @@ use crate::qr::QrCode; -pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String { +pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>, fg: &[u8; 3], bg: &[u8; 3]) -> String { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; let total = matrix_size + 2 * margin; - let fg = format!( - "#{:02X}{:02X}{:02X}", - qr.fg_color[0], qr.fg_color[1], qr.fg_color[2] - ); - let bg = format!( - "#{:02X}{:02X}{:02X}", - qr.bg_color[0], qr.bg_color[1], qr.bg_color[2] - ); + let fg_hex = format!("#{:02X}{:02X}{:02X}", fg[0], fg[1], fg[2]); + let bg_hex = format!("#{:02X}{:02X}{:02X}", bg[0], bg[1], bg[2]); let dark_count = qr .modules() @@ -25,14 +19,14 @@ pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String { r#""# )); svg.push_str(&format!( - r#""# + r#""# )); for y in 0..matrix_size { for x in 0..matrix_size { if qr.modules()[y as usize][x as usize] { svg.push_str(&format!( - r#""#, + r#""#, x + margin, y + margin )); diff --git a/core/src/text_builder.rs b/core/src/text_builder.rs index eaf549d..f6ea240 100644 --- a/core/src/text_builder.rs +++ b/core/src/text_builder.rs @@ -24,23 +24,24 @@ pub fn build_vcard_text( ) -> String { let mut s = format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}"); + use std::fmt::Write; if !title.is_empty() { - s.push_str(&format!("\nTITLE:{title}")); + write!(s, "\nTITLE:{title}").unwrap(); } if !address.is_empty() { - s.push_str(&format!("\nADR:{address}")); + write!(s, "\nADR:{address}").unwrap(); } if !url.is_empty() { - s.push_str(&format!("\nURL:{url}")); + write!(s, "\nURL:{url}").unwrap(); } if !birthday.is_empty() { - s.push_str(&format!("\nBDAY:{birthday}")); + write!(s, "\nBDAY:{birthday}").unwrap(); } if !note.is_empty() { - s.push_str(&format!("\nNOTE:{note}")); + write!(s, "\nNOTE:{note}").unwrap(); } if !photo.is_empty() { - s.push_str(&format!("\nPHOTO:{photo}")); + write!(s, "\nPHOTO:{photo}").unwrap(); } s.push_str("\nEND:VCARD"); s diff --git a/core/src/version.rs b/core/src/version.rs index aab2534..987b6d4 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -1,4 +1,6 @@ +use crate::error::QrError; use serde::Serialize; +use std::str::FromStr; use std::sync::OnceLock; /// QR 码纠错级别 @@ -38,6 +40,20 @@ impl EcLevel { } } +impl FromStr for EcLevel { + type Err = QrError; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "L" => Ok(EcLevel::L), + "M" => Ok(EcLevel::M), + "Q" => Ok(EcLevel::Q), + "H" => Ok(EcLevel::H), + _ => Err(QrError::InvalidVersion(0)), // 复用变体 + } + } +} + /// QR 码版本号(1~40) /// /// 版本决定 QR 码的物理尺寸:`side = 17 + version × 4` 模块。 diff --git a/core/tests/integration_test.rs b/core/tests/integration_test.rs index ea167a7..0890498 100644 --- a/core/tests/integration_test.rs +++ b/core/tests/integration_test.rs @@ -193,11 +193,11 @@ fn test_format_info_written() { #[test] fn test_svg_valid_structure() { let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap(); - let svg = qr.to_svg(None); + let svg = qr.to_svg(None, None, None); // SVG 应有正确的结构 assert!(svg.starts_with("\n") || svg.ends_with(""), "SVG 应以 结尾" @@ -208,7 +208,6 @@ fn test_svg_valid_structure() { fn test_quiet_zone_is_white() { let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap(); let m = qr.modules(); - let s = qr.size() as usize; // 左上角分隔符区域 (7,0..7) 和 (0..7,7) 应为白色 for i in 0..8usize { assert!(!m[7][i], "定位分隔符 (7,{}) 应为白色", i); @@ -313,10 +312,10 @@ fn test_empty_input_fails() { #[test] fn test_svg_output() { let qr = QrCode::encode("TEST", QrConfig::default()).unwrap(); - let svg = qr.to_svg(None); + let svg = qr.to_svg(None, None, None); assert!(svg.contains("")); - assert!(svg.contains("fill=\"black\"")); + assert!(svg.contains("fill=\"#000000\"")); } #[test] @@ -356,7 +355,7 @@ fn test_margin_is_included_in_dimensions() { let qr = QrCode::encode("MARGIN TEST", config).unwrap(); // SVG 的总宽度应该包含 margin - let svg = qr.to_svg(None); + let svg = qr.to_svg(None, None, None); let matrix_size = qr.size() as u32; let expected_total = matrix_size + 2 * 2u32; assert!(svg.contains(&format!("width=\"{}\"", expected_total))); diff --git a/examples/basic_qr.rs b/examples/basic_qr.rs index 7a1e423..aea1848 100644 --- a/examples/basic_qr.rs +++ b/examples/basic_qr.rs @@ -1,6 +1,6 @@ -//! QRGen 基础示例:生成 QR 码并导出为多种格式 -//! -//! 运行: `cargo run --example basic_qr` +/// QRGen 基础示例:生成 QR 码并导出为多种格式 +/// +/// 运行: `cargo run --example basic_qr` use qr_core::qr::{QrCode, QrConfig}; @@ -19,11 +19,11 @@ fn main() -> Result<(), Box> { println!("{}", qr.to_ascii(false)); // 导出 PNG - qr.to_png_bytes(8, None)?; - println!("\nPNG 生成成功"); + let _png = qr.to_png_bytes(8, None)?; + println!("\nPNG 生成成功 ({} 字节)", _png.len()); // 导出 SVG - let svg = qr.to_svg(None); + let svg = qr.to_svg(None, None, None); println!("SVG 长度: {} 字节", svg.len()); Ok(()) diff --git a/examples/custom_config.rs b/examples/custom_config.rs index 44fbf07..ac4460a 100644 --- a/examples/custom_config.rs +++ b/examples/custom_config.rs @@ -1,6 +1,6 @@ -//! QRGen 自定义配置:强制指定版本、模块大小 -//! -//! 运行: `cargo run --example custom_config` +/// QRGen 自定义配置:强制指定版本、模块大小 +/// +/// 运行: `cargo run --example custom_config` use qr_core::qr::{QrCode, QrConfig, VersionMode}; use qr_core::version::EcLevel; @@ -11,6 +11,7 @@ fn main() -> Result<(), Box> { level: EcLevel::Q, version: VersionMode::Fixed(10), margin: 4, + ..Default::default() }; let qr = QrCode::encode("固定版本 10 的 QR 码", config)?; diff --git a/examples/high_ecc.rs b/examples/high_ecc.rs index 041f483..f872017 100644 --- a/examples/high_ecc.rs +++ b/examples/high_ecc.rs @@ -1,6 +1,6 @@ -//! QRGen 高纠错示例:生成可抵抗 30% 损坏的 QR 码 -//! -//! 运行: `cargo run --example high_ecc` +/// QRGen 高纠错示例:生成可抵抗 30% 损坏的 QR 码 +/// +/// 运行: `cargo run --example high_ecc` use qr_core::qr::{QrCode, QrConfig, VersionMode}; use qr_core::version::EcLevel; @@ -10,6 +10,7 @@ fn main() -> Result<(), Box> { level: EcLevel::H, // 30% 纠错能力 version: VersionMode::Auto, margin: 6, // 更大的静区 + ..Default::default() }; let qr = QrCode::encode("重要数据 - High ECC", config)?; @@ -21,7 +22,7 @@ fn main() -> Result<(), Box> { qr.size() ); - let svg = qr.to_svg(None); + let svg = qr.to_svg(None, None, None); println!("SVG 生成成功: {} 字节", svg.len()); Ok(()) diff --git a/gui/src-frontend/src/App.tsx b/gui/src-frontend/src/App.tsx index 0e8033b..8ba42ea 100644 --- a/gui/src-frontend/src/App.tsx +++ b/gui/src-frontend/src/App.tsx @@ -51,7 +51,7 @@ function AppLayout() { {/* 底部输入区 */} -
+
@@ -59,7 +59,7 @@ function AppLayout() { } function BottomInput() { - const { state } = useQrState(); + const state = useQrState(); switch (state.mode) { case 'text': diff --git a/gui/src-frontend/src/__tests__/qrContext.test.tsx b/gui/src-frontend/src/__tests__/qrContext.test.tsx index 422b7ac..ece3c54 100644 --- a/gui/src-frontend/src/__tests__/qrContext.test.tsx +++ b/gui/src-frontend/src/__tests__/qrContext.test.tsx @@ -3,14 +3,20 @@ */ import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { QrProvider, useQrState } from '../store/qrContext'; +import { QrProvider, useQrState, useQrDispatch } from '../store/qrContext'; -describe('QrProvider + useQrState', () => { +function useQr() { + const state = useQrState(); + const dispatch = useQrDispatch(); + return { state, dispatch }; +} + +describe('QrProvider + split context', () => { it('provides default state', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); + const { result } = renderHook(() => useQr(), { wrapper }); expect(result.current.state.mode).toBe('text'); expect(result.current.state.config.level).toBe('M'); expect(result.current.state.config.margin).toBe(4); @@ -23,7 +29,7 @@ describe('QrProvider + useQrState', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); + const { result } = renderHook(() => useQr(), { wrapper }); act(() => result.current.dispatch({ type: 'SET_MODE', payload: 'wifi' })); expect(result.current.state.mode).toBe('wifi'); }); @@ -32,13 +38,8 @@ describe('QrProvider + useQrState', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); - act(() => - result.current.dispatch({ - type: 'SET_FORM_DATA', - payload: { text: 'hello' }, - }), - ); + const { result } = renderHook(() => useQr(), { wrapper }); + act(() => result.current.dispatch({ type: 'SET_FORM_DATA', payload: { text: 'hello' } })); expect(result.current.state.formData.text).toBe('hello'); }); @@ -46,13 +47,8 @@ describe('QrProvider + useQrState', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); - act(() => - result.current.dispatch({ - type: 'SET_CONFIG', - payload: { level: 'H' }, - }), - ); + const { result } = renderHook(() => useQr(), { wrapper }); + act(() => result.current.dispatch({ type: 'SET_CONFIG', payload: { level: 'H' } })); expect(result.current.state.config.level).toBe('H'); expect(result.current.state.config.margin).toBe(4); // unchanged }); @@ -61,7 +57,7 @@ describe('QrProvider + useQrState', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); + const { result } = renderHook(() => useQr(), { wrapper }); act(() => result.current.dispatch({ type: 'SET_PREVIEW', @@ -76,7 +72,7 @@ describe('QrProvider + useQrState', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); - const { result } = renderHook(() => useQrState(), { wrapper }); + const { result } = renderHook(() => useQr(), { wrapper }); act(() => result.current.dispatch({ type: 'SET_HISTORY', diff --git a/gui/src-frontend/src/__tests__/qrText.test.ts b/gui/src-frontend/src/__tests__/qrText.test.ts deleted file mode 100644 index 10cf555..0000000 --- a/gui/src-frontend/src/__tests__/qrText.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * QR 编码文本构造工具 — 单元测试 - */ -import { describe, it, expect } from 'vitest'; -import { - buildWifiText, - buildVCardText, - buildEmailText, - buildPhoneText, - buildSmsText, - buildEncodedText, -} from '@/utils/qrText'; - -describe('buildWifiText', () => { - it('构造 WPA WiFi 字符串', () => { - const result = buildWifiText({ - ssid: 'MyWiFi', - encryption: 'WPA', - password: 'pass123', - }); - expect(result).toBe('WIFI:T:WPA;S:MyWiFi;P:pass123;;'); - }); - - it('空 SSID 返回空字符串', () => { - const result = buildWifiText({ ssid: '' }); - expect(result).toBe(''); - }); - - it('隐藏网络标记正确', () => { - const result = buildWifiText({ - ssid: 'HiddenNet', - encryption: 'WPA2', - password: 'secret', - hidden: 'true', - }); - expect(result).toBe('WIFI:T:WPA2;S:HiddenNet;P:secret;H:true;;'); - }); - - it('默认加密方式为 WPA', () => { - const result = buildWifiText({ ssid: 'Test' }); - expect(result).toContain('T:WPA'); - }); -}); - -describe('buildVCardText', () => { - it('构造完整 vCard', () => { - const result = buildVCardText({ - name: '张三', - phone: '13800138000', - email: 'zhangsan@example.com', - company: '测试公司', - address: '北京市', - }); - expect(result).toContain('BEGIN:VCARD'); - expect(result).toContain('VERSION:3.0'); - expect(result).toContain('FN:张三'); - expect(result).toContain('TEL:13800138000'); - expect(result).toContain('EMAIL:zhangsan@example.com'); - expect(result).toContain('END:VCARD'); - }); - - it('空字段产生空值', () => { - const result = buildVCardText({}); - expect(result).toBe('BEGIN:VCARD\nVERSION:3.0\nFN:\nTEL:\nEMAIL:\nORG:\nADR:\nEND:VCARD'); - }); -}); - -describe('buildEmailText', () => { - it('构造 mailto 链接', () => { - const result = buildEmailText({ - to: 'test@example.com', - subject: 'Hello', - body: 'Test body', - }); - expect(result).toContain('mailto:test@example.com'); - expect(result).toContain('subject='); - expect(result).toContain('body='); - }); -}); - -describe('buildPhoneText', () => { - it('构造电话链接', () => { - expect(buildPhoneText({ number: '13800138000' })).toBe('tel:13800138000'); - }); -}); - -describe('buildSmsText', () => { - it('构造短信链接', () => { - const result = buildSmsText({ number: '13800138000', message: 'Hi' }); - expect(result).toBe('smsto:13800138000:Hi'); - }); -}); - -describe('buildEncodedText', () => { - it('url 模式返回 url 字段', () => { - const result = buildEncodedText('url', { url: 'https://example.com' }); - expect(result).toBe('https://example.com'); - }); - - it('wifi 模式委托给 buildWifiText', () => { - const result = buildEncodedText('wifi', { ssid: 'Test', encryption: 'WPA' }); - expect(result).toContain('WIFI:T:WPA;S:Test'); - }); - - it('未知模式返回 text 字段', () => { - const result = buildEncodedText('unknown', { text: 'raw text' }); - expect(result).toBe('raw text'); - }); -}); diff --git a/gui/src-frontend/src/components/ExportPanel.tsx b/gui/src-frontend/src/components/ExportPanel.tsx index 937da9a..41cc27e 100644 --- a/gui/src-frontend/src/components/ExportPanel.tsx +++ b/gui/src-frontend/src/components/ExportPanel.tsx @@ -1,16 +1,16 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { open, save } from '@tauri-apps/plugin-dialog'; import { readFile, writeFile } from '@tauri-apps/plugin-fs'; import type { QrConfig } from '../types'; -import { buildEncodedText } from '../utils/qrText'; export default function ExportPanel() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); const [exporting, setExporting] = useState(false); const [errorMsg, setErrorMsg] = useState(null); const [decodedText, setDecodedText] = useState(null); @@ -62,8 +62,12 @@ export default function ExportPanel() { setExporting(false); return; } + const qrText = await invoke('build_qr_text', { + mode: state.mode, + formData: state.formData, + }); const bytes: number[] = await invoke('export_png', { - text: buildEncodedText(state.mode, state.formData), + text: qrText, level: state.config.level, margin: state.config.margin, moduleSize: state.config.moduleSize, diff --git a/gui/src-frontend/src/components/HistoryList.tsx b/gui/src-frontend/src/components/HistoryList.tsx index 1a3ad58..8184801 100644 --- a/gui/src-frontend/src/components/HistoryList.tsx +++ b/gui/src-frontend/src/components/HistoryList.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; import { type HistoryEntry } from '../types'; -import { persistHistory } from '../hooks/useQrEncode'; +import { persistHistory } from '../utils/storage'; const MODE_I18N: Record = { text: 'mode.text', @@ -15,7 +15,8 @@ const MODE_I18N: Record = { export default function HistoryList() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); const handleClick = (entry: HistoryEntry) => { dispatch({ type: 'SET_MODE', payload: entry.mode }); diff --git a/gui/src-frontend/src/components/ModePanel.tsx b/gui/src-frontend/src/components/ModePanel.tsx index 1fb8bd9..b0e7831 100644 --- a/gui/src-frontend/src/components/ModePanel.tsx +++ b/gui/src-frontend/src/components/ModePanel.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; import { MODES } from '../types'; const MODE_LABELS: Record = { @@ -14,7 +14,8 @@ const MODE_LABELS: Record = { export default function ModePanel() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); return (
diff --git a/gui/src-frontend/src/components/QrPreview.tsx b/gui/src-frontend/src/components/QrPreview.tsx index 078e021..72b57cb 100644 --- a/gui/src-frontend/src/components/QrPreview.tsx +++ b/gui/src-frontend/src/components/QrPreview.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQrState } from '../store/qrContext'; @@ -10,23 +10,38 @@ function svgToDataUrl(svg: string): string { return `data:image/svg+xml;base64,${btoa(binStr)}`; } -export default function QrPreview() { +/** QR 码预览组件(纯展示,用 React.memo 避免不必要的重渲染) */ +const QrPreview = memo(function QrPreview() { const { t } = useTranslation(); - const { state } = useQrState(); + const state = useQrState(); const svgDataUrl = useMemo( () => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null), [state.preview?.svg], ); + const preview = state.preview; const containerCls = 'w-64 h-64 flex items-center justify-center bg-white rounded-xl shadow-sm'; + // 错误状态 + if (state.error) { + return ( +
+
+ {state.error} +
+
+ ); + } + if (!svgDataUrl) { return (
{state.loading ? ( - {t('preview.loading')} + + {t('preview.loading')} + ) : ( {t('preview.empty')} )} @@ -41,17 +56,25 @@ export default function QrPreview() {
QR Code
-
- - {t('preview.version')} {state.preview!.version} - - - {state.preview!.size}×{state.preview!.size} - - - {t('preview.mask')} {state.preview!.mask} - -
+ {preview && ( +
+ + {t('preview.version')} {preview.version} + + + {preview.size}×{preview.size} + + + {t('preview.mask')} {preview.mask} + +
+ )}
); -} +}); + +export default QrPreview; diff --git a/gui/src-frontend/src/hooks/useModeForm.ts b/gui/src-frontend/src/hooks/useModeForm.ts new file mode 100644 index 0000000..fed2b98 --- /dev/null +++ b/gui/src-frontend/src/hooks/useModeForm.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useQrState, useQrDispatch } from '../store/qrContext'; +import { useQrEncode } from './useQrEncode'; +import type { ModeType } from '../types'; + +/** + * 通用模式表单 hook + * + * 封装模式组件中重复的 update 模式: + * 1. 合并 formData + * 2. dispatch SET_FORM_DATA + * 3. 调用 Rust text_builder 构建 QR 文本 + * 4. 触达编码 + */ +export function useModeForm(mode: ModeType) { + const state = useQrState(); + const dispatch = useQrDispatch(); + const { encode } = useQrEncode(); + + const update = useCallback( + async (field: string, value: string) => { + const data = { ...state.formData, [field]: value }; + dispatch({ type: 'SET_FORM_DATA', payload: data }); + try { + const text = await invoke('build_qr_text', { mode, formData: data }); + encode(text); + } catch { + /* 错误在 encode 中通过 SET_ERROR dispatch 处理 */ + } + }, + [mode, state.formData, encode, dispatch], + ); + + return { formData: state.formData, update }; +} diff --git a/gui/src-frontend/src/hooks/useQrEncode.ts b/gui/src-frontend/src/hooks/useQrEncode.ts index 0fb4793..0ec2111 100644 --- a/gui/src-frontend/src/hooks/useQrEncode.ts +++ b/gui/src-frontend/src/hooks/useQrEncode.ts @@ -1,21 +1,9 @@ import { useCallback, useRef, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Store } from '@tauri-apps/plugin-store'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; +import { persistHistory } from '../utils/storage'; import type { HistoryEntry, ModeType } from '../types'; -const HISTORY_KEY = 'qr-history'; -const STORE_FILE = 'history.json'; - -/** 缓存的 Store 实例,避免每次编码都重新加载 */ -let storeCache: Promise | null = null; -function getStore(): Promise { - if (!storeCache) { - storeCache = Store.load(STORE_FILE); - } - return storeCache; -} - interface QrResponse { svg: string; version: number; @@ -31,37 +19,19 @@ function sanitizeContent(mode: ModeType, content: string): string { return content; } -/** - * 持久化整个历史列表到 store - * 作为内存状态的唯一持久化出口 - */ -export async function persistHistory(history: HistoryEntry[]): Promise { - try { - const store = await getStore(); - await store.set(HISTORY_KEY, history); - await store.save(); - } catch { - /* store 不可用时静默忽略 */ - } -} - -/** 从 store 加载历史记录(应用启动时调用) */ -export async function loadHistory(): Promise { - try { - const store = await getStore(); - return (await store.get(HISTORY_KEY)) || []; - } catch { - return []; - } -} - export function useQrEncode() { - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); const timerRef = useRef | null>(null); + + // 用 ref 持有最新值,避免 encode 回调依赖 formData/history const modeRef = useRef(state.mode); modeRef.current = state.mode; + const formDataRef = useRef(state.formData); + formDataRef.current = state.formData; + const historyRef = useRef(state.history); + historyRef.current = state.history; - // 组件卸载时清理定时器 useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); @@ -72,10 +42,12 @@ export function useQrEncode() { (text: string) => { if (!text.trim()) { dispatch({ type: 'SET_PREVIEW', payload: null }); + dispatch({ type: 'SET_ERROR', payload: null }); return; } dispatch({ type: 'SET_LOADING', payload: true }); + dispatch({ type: 'SET_ERROR', payload: null }); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(async () => { @@ -87,30 +59,26 @@ export function useQrEncode() { }); dispatch({ type: 'SET_PREVIEW', payload: result }); - // 保存到历史(内存 + 持久化) - const entryId = Date.now().toString(); - const currentMode = modeRef.current; const entry: HistoryEntry = { - id: entryId, - mode: currentMode, - content: sanitizeContent(currentMode, text), + id: Date.now().toString(), + mode: modeRef.current, + content: sanitizeContent(modeRef.current, text), timestamp: Date.now(), - formData: { ...state.formData }, + formData: { ...formDataRef.current }, }; dispatch({ type: 'ADD_HISTORY', payload: entry }); - - // 从内存状态持久化(避免 store 读写竞态) - // 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表 - // 确保持久化的数据与内存一致 - persistHistory([entry, ...state.history].slice(0, 50)); - } catch { - // 编码失败时清空预览 + persistHistory([entry, ...historyRef.current].slice(0, 50)); + } catch (e) { dispatch({ type: 'SET_PREVIEW', payload: null }); + dispatch({ + type: 'SET_ERROR', + payload: e instanceof Error ? e.message : '编码失败,请检查输入内容', + }); } }, 200); }, - [state.config.level, state.config.margin, state.formData, state.history, dispatch], + [state.config.level, state.config.margin, dispatch], ); - return { encode, persistHistory }; + return { encode }; } diff --git a/gui/src-frontend/src/modes/EmailMode.tsx b/gui/src-frontend/src/modes/EmailMode.tsx index 3502709..184f102 100644 --- a/gui/src-frontend/src/modes/EmailMode.tsx +++ b/gui/src-frontend/src/modes/EmailMode.tsx @@ -1,36 +1,27 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; -import { useQrEncode } from '../hooks/useQrEncode'; -import { buildEmailText } from '../utils/qrText'; +import { useModeForm } from '../hooks/useModeForm'; export default function EmailMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); - const { encode } = useQrEncode(); - - const update = (field: string, value: string) => { - const data = { ...state.formData, [field]: value }; - dispatch({ type: 'SET_FORM_DATA', payload: data }); - encode(buildEmailText(data)); - }; + const { formData, update } = useModeForm('email'); return (
update('to', e.target.value)} className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> update('subject', e.target.value)} className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> update('body', e.target.value)} className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> diff --git a/gui/src-frontend/src/modes/PhoneMode.tsx b/gui/src-frontend/src/modes/PhoneMode.tsx index 8d3187f..72ccddf 100644 --- a/gui/src-frontend/src/modes/PhoneMode.tsx +++ b/gui/src-frontend/src/modes/PhoneMode.tsx @@ -1,24 +1,16 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; -import { useQrEncode } from '../hooks/useQrEncode'; -import { buildPhoneText } from '../utils/qrText'; +import { useModeForm } from '../hooks/useModeForm'; export default function PhoneMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); - const { encode } = useQrEncode(); - - const update = (number: string) => { - dispatch({ type: 'SET_FORM_DATA', payload: { number } }); - encode(buildPhoneText({ number })); - }; + const { formData, update } = useModeForm('phone'); return ( update(e.target.value)} + placeholder={t('phone.placeholder')} + value={formData.number || ''} + onChange={(e) => update('number', e.target.value)} className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600 focus:ring-2 focus:ring-blue-500/30" /> ); diff --git a/gui/src-frontend/src/modes/SmsMode.tsx b/gui/src-frontend/src/modes/SmsMode.tsx index 83caae4..7fe605f 100644 --- a/gui/src-frontend/src/modes/SmsMode.tsx +++ b/gui/src-frontend/src/modes/SmsMode.tsx @@ -1,31 +1,22 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; -import { useQrEncode } from '../hooks/useQrEncode'; -import { buildSmsText } from '../utils/qrText'; +import { useModeForm } from '../hooks/useModeForm'; export default function SmsMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); - const { encode } = useQrEncode(); - - const update = (field: string, value: string) => { - const data = { ...state.formData, [field]: value }; - dispatch({ type: 'SET_FORM_DATA', payload: data }); - encode(buildSmsText(data)); - }; + const { formData, update } = useModeForm('sms'); return (
update('number', e.target.value)} className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> update('message', e.target.value)} className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> diff --git a/gui/src-frontend/src/modes/TextMode.tsx b/gui/src-frontend/src/modes/TextMode.tsx index bcd605f..805cfef 100644 --- a/gui/src-frontend/src/modes/TextMode.tsx +++ b/gui/src-frontend/src/modes/TextMode.tsx @@ -1,10 +1,11 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; import { useQrEncode } from '../hooks/useQrEncode'; export default function TextMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); const { encode } = useQrEncode(); const handleChange = (text: string) => { diff --git a/gui/src-frontend/src/modes/UrlMode.tsx b/gui/src-frontend/src/modes/UrlMode.tsx index e7711d2..a0e5575 100644 --- a/gui/src-frontend/src/modes/UrlMode.tsx +++ b/gui/src-frontend/src/modes/UrlMode.tsx @@ -1,10 +1,11 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; +import { useQrState, useQrDispatch } from '../store/qrContext'; import { useQrEncode } from '../hooks/useQrEncode'; export default function UrlMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); + const state = useQrState(); + const dispatch = useQrDispatch(); const { encode } = useQrEncode(); const handleChange = (url: string) => { diff --git a/gui/src-frontend/src/modes/VCardMode.tsx b/gui/src-frontend/src/modes/VCardMode.tsx index 2c8a325..195e2b9 100644 --- a/gui/src-frontend/src/modes/VCardMode.tsx +++ b/gui/src-frontend/src/modes/VCardMode.tsx @@ -1,41 +1,32 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; -import { useQrEncode } from '../hooks/useQrEncode'; -import { buildVCardText } from '../utils/qrText'; +import { useModeForm } from '../hooks/useModeForm'; const FIELDS = [ - { key: 'name', i18n: 'vcard.name' }, - { key: 'phone', i18n: 'vcard.phone' }, - { key: 'email', i18n: 'vcard.email' }, - { key: 'company', i18n: 'vcard.company' }, - { key: 'title', i18n: 'vcard.title' }, - { key: 'address', i18n: 'vcard.address' }, - { key: 'vcardUrl', i18n: 'vcard.url' }, - { key: 'birthday', i18n: 'vcard.birthday' }, - { key: 'note', i18n: 'vcard.note' }, - { key: 'photo', i18n: 'vcard.photo' }, -]; + 'name', + 'phone', + 'email', + 'company', + 'title', + 'address', + 'vcardUrl', + 'birthday', + 'note', + 'photo', +] as const; export default function VCardMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); - const { encode } = useQrEncode(); - - const update = (field: string, value: string) => { - const data = { ...state.formData, [field]: value }; - dispatch({ type: 'SET_FORM_DATA', payload: data }); - encode(buildVCardText(data)); - }; + const { formData, update } = useModeForm('vcard'); return ( -
- {FIELDS.map((f) => ( +
+ {FIELDS.map((key) => ( update(f.key, e.target.value)} - className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" + key={key} + placeholder={t(`vcard.${key}`)} + value={formData[key] || ''} + onChange={(e) => update(key, e.target.value)} + className="px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 text-xs bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> ))}
diff --git a/gui/src-frontend/src/modes/WifiMode.tsx b/gui/src-frontend/src/modes/WifiMode.tsx index 9b5bc25..03508bd 100644 --- a/gui/src-frontend/src/modes/WifiMode.tsx +++ b/gui/src-frontend/src/modes/WifiMode.tsx @@ -1,48 +1,38 @@ import { useTranslation } from 'react-i18next'; -import { useQrState } from '../store/qrContext'; -import { useQrEncode } from '../hooks/useQrEncode'; -import { buildWifiText } from '../utils/qrText'; +import { useModeForm } from '../hooks/useModeForm'; export default function WifiMode() { const { t } = useTranslation(); - const { state, dispatch } = useQrState(); - const { encode } = useQrEncode(); - - /** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */ - const update = (field: string, value: string) => { - const data = { ...state.formData, [field]: value }; - dispatch({ type: 'SET_FORM_DATA', payload: data }); - encode(buildWifiText(data)); - }; + const { formData, update } = useModeForm('wifi'); return (
update('ssid', e.target.value)} className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" /> update('password', e.target.value)} className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30" />