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:
2026-06-19 21:38:58 +08:00
parent b41f6ee7df
commit 86d788e57c
8 changed files with 294 additions and 9 deletions
+12 -1
View File
@@ -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)
}
+160
View File
@@ -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
}
+63 -3
View File
@@ -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]