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)]
|
#[arg(long)]
|
||||||
address: Option<String>,
|
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)
|
/// 收件人 (Email)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
to: Option<String>,
|
to: Option<String>,
|
||||||
@@ -255,6 +275,11 @@ fn build_text_from_args(args: &Args) -> anyhow::Result<String> {
|
|||||||
args.email.as_deref().unwrap_or(""),
|
args.email.as_deref().unwrap_or(""),
|
||||||
args.company.as_deref().unwrap_or(""),
|
args.company.as_deref().unwrap_or(""),
|
||||||
args.address.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") => {
|
Some("email") => {
|
||||||
let to = args
|
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 em = entry.email.as_deref().unwrap_or("");
|
||||||
let co = entry.company.as_deref().unwrap_or("");
|
let co = entry.company.as_deref().unwrap_or("");
|
||||||
let ad = entry.address.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 {
|
if let Some(t) = &entry.to {
|
||||||
let s = entry.subject.as_deref().unwrap_or("");
|
let s = entry.subject.as_deref().unwrap_or("");
|
||||||
|
|||||||
+12
-1
@@ -19,6 +19,7 @@ mod extract;
|
|||||||
mod format;
|
mod format;
|
||||||
mod image;
|
mod image;
|
||||||
mod mode_decode;
|
mod mode_decode;
|
||||||
|
mod perspective;
|
||||||
mod rs_decode;
|
mod rs_decode;
|
||||||
|
|
||||||
use crate::matrix::mask::apply_mask;
|
use crate::matrix::mask::apply_mask;
|
||||||
@@ -48,7 +49,17 @@ pub struct DecodeResult {
|
|||||||
/// `DecodeResult` 包含解码文本和元信息
|
/// `DecodeResult` 包含解码文本和元信息
|
||||||
pub fn decode_image(bytes: &[u8]) -> Result<DecodeResult, String> {
|
pub fn decode_image(bytes: &[u8]) -> Result<DecodeResult, String> {
|
||||||
let gray = image::load_and_binarize(bytes)?;
|
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)
|
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};")
|
format!("WIFI:T:{encryption};S:{ssid};P:{password};{h};")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构造 vCard 字符串
|
/// 构造 vCard 3.0 字符串(含扩展字段)
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_vcard_text(
|
pub fn build_vcard_text(
|
||||||
name: &str,
|
name: &str,
|
||||||
phone: &str,
|
phone: &str,
|
||||||
email: &str,
|
email: &str,
|
||||||
company: &str,
|
company: &str,
|
||||||
address: &str,
|
address: &str,
|
||||||
|
title: &str,
|
||||||
|
url: &str,
|
||||||
|
birthday: &str,
|
||||||
|
note: &str,
|
||||||
|
photo: &str,
|
||||||
) -> String {
|
) -> 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 链接
|
/// 构造 mailto 链接
|
||||||
@@ -71,10 +98,43 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_vcard_text() {
|
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("BEGIN:VCARD"));
|
||||||
assert!(text.contains("FN:张三"));
|
assert!(text.contains("FN:张三"));
|
||||||
assert!(text.contains("END:VCARD"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -47,7 +47,12 @@
|
|||||||
"phone": "Phone",
|
"phone": "Phone",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"company": "Company",
|
"company": "Company",
|
||||||
"address": "Address"
|
"title": "Title",
|
||||||
|
"address": "Address",
|
||||||
|
"url": "URL",
|
||||||
|
"birthday": "Birthday",
|
||||||
|
"note": "Note",
|
||||||
|
"photo": "Photo URL"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"to": "To",
|
"to": "To",
|
||||||
|
|||||||
@@ -47,7 +47,12 @@
|
|||||||
"phone": "电话",
|
"phone": "电话",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"company": "公司",
|
"company": "公司",
|
||||||
"address": "地址"
|
"title": "职位",
|
||||||
|
"address": "地址",
|
||||||
|
"url": "网址",
|
||||||
|
"birthday": "生日",
|
||||||
|
"note": "备注",
|
||||||
|
"photo": "照片URL"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"to": "收件人",
|
"to": "收件人",
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ const FIELDS = [
|
|||||||
{ key: 'phone', i18n: 'vcard.phone' },
|
{ key: 'phone', i18n: 'vcard.phone' },
|
||||||
{ key: 'email', i18n: 'vcard.email' },
|
{ key: 'email', i18n: 'vcard.email' },
|
||||||
{ key: 'company', i18n: 'vcard.company' },
|
{ key: 'company', i18n: 'vcard.company' },
|
||||||
|
{ key: 'title', i18n: 'vcard.title' },
|
||||||
{ key: 'address', i18n: 'vcard.address' },
|
{ 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() {
|
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};`;
|
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 构造 vCard 字符串 */
|
/** 构造 vCard 3.0 字符串(含扩展字段) */
|
||||||
export function buildVCardText(formData: Record<string, string>): string {
|
export function buildVCardText(formData: Record<string, string>): string {
|
||||||
const name = formData.name || '';
|
const name = formData.name || '';
|
||||||
const phone = formData.phone || '';
|
const phone = formData.phone || '';
|
||||||
const email = formData.email || '';
|
const email = formData.email || '';
|
||||||
const company = formData.company || '';
|
const company = formData.company || '';
|
||||||
const address = formData.address || '';
|
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 链接 */
|
/** 构造 mailto 链接 */
|
||||||
|
|||||||
Reference in New Issue
Block a user