feat: vCard 扩展 + 格式扩展 + 解码透视矫正 — v0.3.0
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 前端全部通过
This commit is contained in:
+28
-1
@@ -100,6 +100,26 @@ struct Args {
|
||||
#[arg(long)]
|
||||
address: Option<String>,
|
||||
|
||||
/// 职位 (vCard)
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
|
||||
/// 个人网址 (vCard)
|
||||
#[arg(long = "vcard-url")]
|
||||
vcard_url: Option<String>,
|
||||
|
||||
/// 生日 YYYY-MM-DD (vCard)
|
||||
#[arg(long)]
|
||||
birthday: Option<String>,
|
||||
|
||||
/// 备注 (vCard)
|
||||
#[arg(long)]
|
||||
note: Option<String>,
|
||||
|
||||
/// 照片 URL (vCard)
|
||||
#[arg(long)]
|
||||
photo: Option<String>,
|
||||
|
||||
/// 收件人 (Email)
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
@@ -255,6 +275,11 @@ fn build_text_from_args(args: &Args) -> anyhow::Result<String> {
|
||||
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<String> {
|
||||
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("");
|
||||
|
||||
+12
-1
@@ -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<DecodeResult, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! QR 码图像透视矫正
|
||||
//!
|
||||
//! 检测定位图案后,计算旋转角并矫正图像。
|
||||
//! MVP 版本:仅做旋转矫正(仿射变换),不做完整单应变换。
|
||||
|
||||
pub(crate) fn auto_correct(gray: &[Vec<bool>]) -> Vec<Vec<bool>> {
|
||||
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<bool>]) -> 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<bool>], 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<bool>],
|
||||
tl: (usize, usize),
|
||||
tr: (usize, usize),
|
||||
) -> Vec<Vec<bool>> {
|
||||
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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
"phone": "电话",
|
||||
"email": "邮箱",
|
||||
"company": "公司",
|
||||
"address": "地址"
|
||||
"title": "职位",
|
||||
"address": "地址",
|
||||
"url": "网址",
|
||||
"birthday": "生日",
|
||||
"note": "备注",
|
||||
"photo": "照片URL"
|
||||
},
|
||||
"email": {
|
||||
"to": "收件人",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -14,14 +14,26 @@ export function buildWifiText(formData: Record<string, string>): string {
|
||||
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
|
||||
}
|
||||
|
||||
/** 构造 vCard 字符串 */
|
||||
/** 构造 vCard 3.0 字符串(含扩展字段) */
|
||||
export function buildVCardText(formData: Record<string, string>): 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 链接 */
|
||||
|
||||
Reference in New Issue
Block a user