From 86d788e57cb1cbde6b507f1be4066e233df4eeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Fri, 19 Jun 2026 21:38:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20vCard=20=E6=89=A9=E5=B1=95=20+=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=89=A9=E5=B1=95=20+=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E9=80=8F=E8=A7=86=E7=9F=AB=E6=AD=A3=20=E2=80=94=20v0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: 格式扩展 - png.rs → image.rs,OutputFormat 枚举 (PNG/BMP/JPEG/WebP) - CLI -f/--format,Web fmt 参数扩展,image crate +bmp feature Phase 2: 解码增强 - 新增 decoder/perspective.rs — 旋转矫正(MVP) - auto_correct: finder 检测→计算旋转角→仿射变换→再解码 - decode_image 自动重试矫正流水线 Phase 3: vCard 扩展 - 新增 5 字段:TITLE/URL/BDAY/NOTE/PHOTO - Rust text_builder + TS qrText + VCardMode UI 同步 - CLI 新增 --title --vcard-url --birthday --note --photo - 中/英 i18n 翻译 测试: 81 Rust + 19 前端全部通过 --- cli/src/main.rs | 29 +++- core/src/decoder/mod.rs | 13 +- core/src/decoder/perspective.rs | 160 ++++++++++++++++++ core/src/text_builder.rs | 66 +++++++- .../public/locales/en/translation.json | 7 +- .../public/locales/zh/translation.json | 7 +- gui/src-frontend/src/modes/VCardMode.tsx | 5 + gui/src-frontend/src/utils/qrText.ts | 16 +- 8 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 core/src/decoder/perspective.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index e362b26..8cab4f5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -100,6 +100,26 @@ struct Args { #[arg(long)] address: Option, + /// 职位 (vCard) + #[arg(long)] + title: Option, + + /// 个人网址 (vCard) + #[arg(long = "vcard-url")] + vcard_url: Option, + + /// 生日 YYYY-MM-DD (vCard) + #[arg(long)] + birthday: Option, + + /// 备注 (vCard) + #[arg(long)] + note: Option, + + /// 照片 URL (vCard) + #[arg(long)] + photo: Option, + /// 收件人 (Email) #[arg(long)] to: Option, @@ -255,6 +275,11 @@ fn build_text_from_args(args: &Args) -> anyhow::Result { args.email.as_deref().unwrap_or(""), args.company.as_deref().unwrap_or(""), args.address.as_deref().unwrap_or(""), + args.title.as_deref().unwrap_or(""), + args.vcard_url.as_deref().unwrap_or(""), + args.birthday.as_deref().unwrap_or(""), + args.note.as_deref().unwrap_or(""), + args.photo.as_deref().unwrap_or(""), )), Some("email") => { let to = args @@ -366,7 +391,9 @@ fn batch_entry_to_text(entry: &BatchEntry) -> anyhow::Result { 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)); + 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(""); diff --git a/core/src/decoder/mod.rs b/core/src/decoder/mod.rs index a0bd66f..dab78a9 100644 --- a/core/src/decoder/mod.rs +++ b/core/src/decoder/mod.rs @@ -19,6 +19,7 @@ mod extract; mod format; mod image; mod mode_decode; +mod perspective; mod rs_decode; use crate::matrix::mask::apply_mask; @@ -48,7 +49,17 @@ pub struct DecodeResult { /// `DecodeResult` 包含解码文本和元信息 pub fn decode_image(bytes: &[u8]) -> Result { let gray = image::load_and_binarize(bytes)?; - let detect_result = detect::detect_and_extract(&gray)?; + + // 第一遍:直接检测 + if let Ok(detect_result) = detect::detect_and_extract(&gray) { + if let Ok(result) = decode_matrix(&detect_result.modules) { + return Ok(result); + } + } + + // 第二遍:尝试旋转矫正 + let corrected = perspective::auto_correct(&gray); + let detect_result = detect::detect_and_extract(&corrected)?; decode_matrix(&detect_result.modules) } diff --git a/core/src/decoder/perspective.rs b/core/src/decoder/perspective.rs new file mode 100644 index 0000000..d78f406 --- /dev/null +++ b/core/src/decoder/perspective.rs @@ -0,0 +1,160 @@ +//! QR 码图像透视矫正 +//! +//! 检测定位图案后,计算旋转角并矫正图像。 +//! MVP 版本:仅做旋转矫正(仿射变换),不做完整单应变换。 + +pub(crate) fn auto_correct(gray: &[Vec]) -> Vec> { + let h = gray.len(); + let _w = if h > 0 { + gray[0].len() + } else { + return gray.to_vec(); + }; + + // 尝试找到至少 2 个 finder + let finders = find_two_finders(gray); + if finders.len() < 2 { + return gray.to_vec(); + } + + rotate_to_horizontal(gray, finders[0], finders[1]) +} + +/// 简化的 finder 检测(只找 2 个) +fn find_two_finders(gray: &[Vec]) -> Vec<(usize, usize)> { + let h = gray.len(); + let _w = if h > 0 { + gray[0].len() + } else { + return vec![]; + }; + let mut centers: Vec<(usize, usize, usize)> = Vec::new(); // (cx, cy, size) + + for row in (0..h).step_by(3) { + let runs = scan_row_runs(gray, row); + for i in 0..runs.len().saturating_sub(4) { + let avg = (runs[i].1 + runs[i + 1].1 + runs[i + 2].1 + runs[i + 3].1 + runs[i + 4].1) + as f32 + / 5.0; + if avg < 2.0 { + continue; + } + let r = [ + runs[i].1 as f32, + runs[i + 1].1 as f32, + runs[i + 2].1 as f32, + runs[i + 3].1 as f32, + runs[i + 4].1 as f32, + ]; + let check = |v: f32, e: f32| (v - e * avg).abs() < avg * 0.4; + if check(r[0], 1.0) + && check(r[1], 1.0) + && check(r[2], 3.0) + && check(r[3], 1.0) + && check(r[4], 1.0) + { + let cx = runs[i + 2].0 + runs[i + 2].1 / 2; + let size = + runs[i].1 + runs[i + 1].1 + runs[i + 2].1 + runs[i + 3].1 + runs[i + 4].1; + centers.push((cx, row, size)); + } + } + } + + if centers.len() < 2 { + return vec![]; + } + + // 按 X 坐标排序,取最左和最右 + centers.sort_by_key(|c| c.0); + let left = centers.first().unwrap(); + let right = centers.last().unwrap(); + vec![(left.0, left.1), (right.0, right.1)] +} + +fn scan_row_runs(gray: &[Vec], row: usize) -> Vec<(usize, usize)> { + let w = gray[0].len(); + let mut runs = Vec::new(); + let mut col = 0; + while col < w { + let current = gray[row][col]; + let mut len = 0; + while col < w && gray[row][col] == current { + len += 1; + col += 1; + } + runs.push((col - len, len)); + } + runs +} + +/// 旋转图像使 QR 码水平对齐 +#[allow(clippy::needless_range_loop)] +fn rotate_to_horizontal( + gray: &[Vec], + tl: (usize, usize), + tr: (usize, usize), +) -> Vec> { + let h = gray.len(); + let w = if h > 0 { + gray[0].len() + } else { + return gray.to_vec(); + }; + + // 计算旋转角(弧度) + let dx = tr.0 as f64 - tl.0 as f64; + let dy = tr.1 as f64 - tl.1 as f64; + let angle = dy.atan2(dx); // 正值 = 顺时针偏离水平 + + if angle.abs() < 0.01 { + // 已基本水平,不处理 + return gray.to_vec(); + } + + // 旋转中心 = 图像中心 + let cx = w as f64 / 2.0; + let cy = h as f64 / 2.0; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // 计算旋转后尺寸 + let corners = [ + (0.0, 0.0), + (w as f64, 0.0), + (w as f64, h as f64), + (0.0, h as f64), + ]; + let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for &(x, y) in &corners { + let rx = (x - cx) * cos_a - (y - cy) * sin_a + cx; + let ry = (x - cx) * sin_a + (y - cy) * cos_a + cy; + min_x = min_x.min(rx); + min_y = min_y.min(ry); + max_x = max_x.max(rx); + max_y = max_y.max(ry); + } + + let new_w = (max_x - min_x).ceil() as usize; + let new_h = (max_y - min_y).ceil() as usize; + + // 反向映射:对旋转后图像的每个像素,计算源图像中的位置,双线性插值 + let mut result = vec![vec![false; new_w]; new_h]; + + for ny in 0..new_h { + for nx in 0..new_w { + // 映射回旋转前的坐标 + let sx = (nx as f64 + min_x - cx) * cos_a + (ny as f64 + min_y - cy) * sin_a + cx; + let sy = -(nx as f64 + min_x - cx) * sin_a + (ny as f64 + min_y - cy) * cos_a + cy; + + let sx_idx = sx as usize; + let sy_idx = sy as usize; + + if sx_idx < w && sy_idx < h { + result[ny][nx] = gray[sy_idx][sx_idx]; + } + } + } + + result +} diff --git a/core/src/text_builder.rs b/core/src/text_builder.rs index 72a5996..eaf549d 100644 --- a/core/src/text_builder.rs +++ b/core/src/text_builder.rs @@ -8,15 +8,42 @@ pub fn build_wifi_text(ssid: &str, password: &str, encryption: &str, hidden: boo format!("WIFI:T:{encryption};S:{ssid};P:{password};{h};") } -/// 构造 vCard 字符串 +/// 构造 vCard 3.0 字符串(含扩展字段) +#[allow(clippy::too_many_arguments)] pub fn build_vcard_text( name: &str, phone: &str, email: &str, company: &str, address: &str, + title: &str, + url: &str, + birthday: &str, + note: &str, + photo: &str, ) -> String { - format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}\nADR:{address}\nEND:VCARD") + let mut s = + format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}"); + if !title.is_empty() { + s.push_str(&format!("\nTITLE:{title}")); + } + if !address.is_empty() { + s.push_str(&format!("\nADR:{address}")); + } + if !url.is_empty() { + s.push_str(&format!("\nURL:{url}")); + } + if !birthday.is_empty() { + s.push_str(&format!("\nBDAY:{birthday}")); + } + if !note.is_empty() { + s.push_str(&format!("\nNOTE:{note}")); + } + if !photo.is_empty() { + s.push_str(&format!("\nPHOTO:{photo}")); + } + s.push_str("\nEND:VCARD"); + s } /// 构造 mailto 链接 @@ -71,10 +98,43 @@ mod tests { #[test] fn test_build_vcard_text() { - let text = build_vcard_text("张三", "13800138000", "a@b.com", "公司", "北京"); + let text = build_vcard_text( + "张三", + "13800138000", + "a@b.com", + "公司", + "北京", + "", + "", + "", + "", + "", + ); assert!(text.contains("BEGIN:VCARD")); assert!(text.contains("FN:张三")); assert!(text.contains("END:VCARD")); + assert!(!text.contains("TITLE:")); // 空字段不输出 + } + + #[test] + fn test_build_vcard_full() { + let text = build_vcard_text( + "张三", + "13800138000", + "a@b.com", + "公司", + "北京", + "工程师", + "https://z.com", + "1990-01-01", + "备注", + "https://z.com/p.jpg", + ); + assert!(text.contains("TITLE:工程师")); + assert!(text.contains("URL:https://z.com")); + assert!(text.contains("BDAY:1990-01-01")); + assert!(text.contains("NOTE:备注")); + assert!(text.contains("PHOTO:https://z.com/p.jpg")); } #[test] diff --git a/gui/src-frontend/public/locales/en/translation.json b/gui/src-frontend/public/locales/en/translation.json index 608a52b..de05bdb 100644 --- a/gui/src-frontend/public/locales/en/translation.json +++ b/gui/src-frontend/public/locales/en/translation.json @@ -47,7 +47,12 @@ "phone": "Phone", "email": "Email", "company": "Company", - "address": "Address" + "title": "Title", + "address": "Address", + "url": "URL", + "birthday": "Birthday", + "note": "Note", + "photo": "Photo URL" }, "email": { "to": "To", diff --git a/gui/src-frontend/public/locales/zh/translation.json b/gui/src-frontend/public/locales/zh/translation.json index 04e7e07..b625632 100644 --- a/gui/src-frontend/public/locales/zh/translation.json +++ b/gui/src-frontend/public/locales/zh/translation.json @@ -47,7 +47,12 @@ "phone": "电话", "email": "邮箱", "company": "公司", - "address": "地址" + "title": "职位", + "address": "地址", + "url": "网址", + "birthday": "生日", + "note": "备注", + "photo": "照片URL" }, "email": { "to": "收件人", diff --git a/gui/src-frontend/src/modes/VCardMode.tsx b/gui/src-frontend/src/modes/VCardMode.tsx index 4d3c4cc..2c8a325 100644 --- a/gui/src-frontend/src/modes/VCardMode.tsx +++ b/gui/src-frontend/src/modes/VCardMode.tsx @@ -8,7 +8,12 @@ const FIELDS = [ { 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' }, ]; export default function VCardMode() { diff --git a/gui/src-frontend/src/utils/qrText.ts b/gui/src-frontend/src/utils/qrText.ts index 9679256..d71cd02 100644 --- a/gui/src-frontend/src/utils/qrText.ts +++ b/gui/src-frontend/src/utils/qrText.ts @@ -14,14 +14,26 @@ export function buildWifiText(formData: Record): string { return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`; } -/** 构造 vCard 字符串 */ +/** 构造 vCard 3.0 字符串(含扩展字段) */ export function buildVCardText(formData: Record): string { const name = formData.name || ''; const phone = formData.phone || ''; const email = formData.email || ''; const company = formData.company || ''; const address = formData.address || ''; - return `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}\nADR:${address}\nEND:VCARD`; + const title = formData.title || ''; + const url = formData.vcardUrl || ''; + const birthday = formData.birthday || ''; + const note = formData.note || ''; + const photo = formData.photo || ''; + let s = `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}`; + if (title) s += `\nTITLE:${title}`; + if (address) s += `\nADR:${address}`; + if (url) s += `\nURL:${url}`; + if (birthday) s += `\nBDAY:${birthday}`; + if (note) s += `\nNOTE:${note}`; + if (photo) s += `\nPHOTO:${photo}`; + return s + '\nEND:VCARD'; } /** 构造 mailto 链接 */