Compare commits

...

4 Commits

Author SHA1 Message Date
Serendipity 171afab9bb docs: 修复 ROADMAP 版本号 — v0.3.0→已交付, v0.4.0/v1.0.0 去重 2026-06-19 21:42:54 +08:00
Serendipity 7f3b8b4cc7 docs: 更新全部 markdown 文档 — v0.3.0
- README: 测试数 81、输出格式表 + vCard 10字段、透视矫正、格式扩展
- CHANGELOG: v0.3.0 条目(格式扩展+解码增强+vCard扩展)
- ROADMAP: v0.2.0/v0.3.0 移至已交付,更新下一版本规划
- CLAUDE.md: 测试 81→105、perspective.rs 模块、Web fmt 参数
2026-06-19 21:41:49 +08:00
Serendipity 86d788e57c 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 前端全部通过
2026-06-19 21:38:58 +08:00
Serendipity b41f6ee7df feat: 格式扩展 — 支持 BMP/JPEG/WebP 输出
- png.rs 重命名为 image.rs,新增 OutputFormat 枚举
- QrCode::to_image_bytes 支持 PNG/BMP/JPEG/WebP
- CLI 新增 -f/--format 参数(png/bmp/jpeg/webp)
- Web API fmt 参数扩展至全部 4 种图像格式
- core/Cargo.toml: image crate 新增 bmp feature
2026-06-19 21:34:21 +08:00
17 changed files with 465 additions and 77 deletions
+25 -1
View File
@@ -1,5 +1,29 @@
# Changelog
## 0.3.0 (2026-06-19)
### Added
- **格式扩展** — 新增 BMP/JPEG/WebP 图像输出
- `core/src/render/image.rs``OutputFormat` 枚举 (Png/Bmp/Jpeg/WebP)
- `QrCode::to_image_bytes()` — 参数化格式输出
- CLI `-f`/`--format` (png/bmp/jpeg/webp)
- Web API `fmt` 参数扩展至全部 4 种格式
- **解码增强** — 透视矫正
- `core/src/decoder/perspective.rs`:旋转矫正流水线
- 自动检测 finder → 计算旋转角 → 仿射变换 → 再解码
- `decode_image` 自动重试矫正路径
- **vCard 扩展** — 新增 5 字段
- TITLE(职位)/ URL(网址)/ BDAY(生日)/ NOTE(备注)/ PHOTO(照片)
- Rust `text_builder` + TypeScript `qrText` + VCardMode UI 同步
- CLI 新增 `--title` `--vcard-url` `--birthday` `--note` `--photo`
- 中/英 i18n 翻译
### Changed
- `core/src/render/png.rs``image.rs`(格式无关化)
- `QrCode::to_png_bytes` 保留为 `to_image_bytes` 的便捷方法
## 0.1.0 (2026-06-19)
### Added
@@ -65,7 +89,7 @@
- GUIReact Context + useReducer,共享文本构造工具 (utils/qrText.ts)
- CLIclap derive + anyhow 错误处理
- Webaxum 0.8 + tokio,编译期 HTML 嵌入 (include_str!)
- 96 个测试(72 单元 + 24 集成)
- 105 个测试(81 单元 + 24 集成)
- NSIS Windows 安装包 + Docker Alpine 镜像
- 文档:API doc commentsrustdoc 可用)+ 3 个代码示例
- 社区:CONTRIBUTING / CODE_OF_CONDUCT / SECURITY / Issue & PR 模板
+4 -3
View File
@@ -89,6 +89,7 @@ QRGen/
│ │ ├── rs_decode.rs # RS 纠错流水线
│ │ ├── mode_decode.rs # 逆向 4 种编码模式
│ │ ├── detect.rs # 定位图案检测 + 采样网格
│ │ ├── perspective.rs # 透视矫正(旋转+仿射变换)
│ │ └── image.rs # 图像加载 + 二值化
│ ├── matrix/
│ │ ├── grid.rs # 模块矩阵 (含 reserved 保留区)
@@ -145,7 +146,7 @@ QRGen/
| Endpoint | 参数 | 返回 |
|----------|------|------|
| `GET /` | — | HTML 页面(内嵌 7 种编码模式) |
| `GET /api/qr` | `text`, `level`(L/M/Q/H), `margin`(1-20), `size`(2-20), `fmt`(svg) | PNG 或 SVG |
| `GET /api/qr` | `text`, `level`(L/M/Q/H), `margin`(1-20), `size`(2-20), `fmt`(png/bmp/jpeg/webp/svg) | PNG/BMP/JPEG/WebP/SVG |
| `POST /api/decode` | multipart `file` (PNG/JPEG/WebP) | JSON `{text, version, level, mask, errors_corrected}` |
## 前端状态管理
@@ -196,9 +197,9 @@ Action: SET_MODE | SET_FORM_DATA | SET_CONFIG | SET_PREVIEW | SET_LOADING
| 层级 | 数量 | 说明 |
|------|------|------|
| 单元测试 | 72 | Galois 运算、RS 编解码、模式编解码、掩码评分、格式/版本信息 roundtrip、BCH 容错、蛇形提取等 |
| 单元测试 | 81 | Galois 运算、RS 编解码、模式编解码、掩码评分、格式/版本信息 roundtrip、BCH 容错、蛇形提取、vCard 扩展等 |
| 集成测试 | 24 | 端到端编码、渲染输出验证、边距、特殊字符、自动版本选择、格式信息 roundtrip |
| 总计 | 96 | `cargo test` 全部通过 |
| 总计 | 105 | `cargo test` 全部通过 |
## 版本号升级清单
+6 -5
View File
@@ -11,7 +11,7 @@
<img src="https://img.shields.io/badge/axum-0.8-ff6b35" alt="axum">
<img src="https://img.shields.io/badge/docker-ready-2496ed" alt="docker">
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/tests-81%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/clippy-clean-brightgreen" alt="clippy">
<img src="https://img.shields.io/badge/prettier-formatted-ff69b4" alt="prettier">
<img src="https://img.shields.io/badge/eslint-checked-4b32c3" alt="eslint">
@@ -142,7 +142,7 @@ qrgen --decode qr.png
### GUI 桌面应用
- **7 种编码模式**:文本 / URL / WiFi / vCard / Email / 电话 / SMS
- **7 种编码模式**:文本 / URL / WiFi / vCard(10字段) / Email / 电话 / SMS
- **解码**:选择图片文件,解码 QR 码为文本
- **实时预览**200ms 防抖,PNG 即时渲染
- **多格式导出**:PNG(可调模块大小)/ SVG / 复制到剪贴板
@@ -207,7 +207,7 @@ cargo run -p qrgen -- "Hello World"
cargo run -p qrgen-web # → http://localhost:3000
# Rust 测试
cargo test --lib # 72 unit
cargo test --lib # 81 unit
# 前端测试
cd gui/src-frontend && pnpm test # vitest
@@ -310,9 +310,10 @@ QRGen/
| QR 版本 | 1 ~ 4021×21 ~ 177×177 模块) |
| 纠错级别 | L (7%) / M (15%) / Q (25%) / H (30%) |
| 编码模式 | 数字 / 字母数字 / 字节 / 汉字 (Shift JIS) |
| 输出格式 | PNG / SVG / 终端 ASCII |
| 输出格式 | PNG / BMP / JPEG / WebP / SVG / 终端 ASCII |
| vCard 字段 | 姓名/电话/邮箱/公司/职位/地址/网址/生日/备注/照片 (10 字段) |
| 使用方式 | Library / CLI / GUI / Web API |
| 解码 | 从图片识读 QR 码 → 文本(PNG/JPEG/WebP |
| 解码 | 从图片识读 QR 码 → 文本(支持旋转矫正 |
| 自动版本选择 | 根据数据长度 + 纠错级别 |
| Docker 镜像 | ~18MB (alpine) |
+21 -18
View File
@@ -2,34 +2,37 @@
QRGen 的未来发展方向。
## v0.2.0 (下一个版本)
## v0.4.0 (下一个版本)
- [ ] **CLI 编码模式** — CLI 支持 `--mode wifi` 等子命令,免去手动拼 `WIFI:T:...`
- [ ] **Logo 嵌入** — QR 码中央嵌入自定义图片(Logo/头像)
- [ ] **彩色 QR 码** — 自定义前景色/背景色,渐变色支持
- [ ] **批量生成** — 从 CSV/JSON 批量生成 QR 码
- [ ] **前端测试** — vite + vitest + React Testing Library80% 覆盖率
- [ ] **E2E 测试** — Playwright 端到端测试(编码 → 导出 → 历史)
- [ ] **i18n**中英双语界面 (i18next)
## v0.3.0
- [ ] **格式扩展** — 支持 BMP/JPEG/WEBP 输出
- [ ] **解码增强** — 斜拍/旋转图像矫正、模糊图像增强
- [ ] **WiFi 扫码自动连接** — 移动端扫码后一键连接 WiFi
- [ ] **vCard 扩展** — 支持更多字段(照片、社交媒体等)
- [ ] **macOS 桌面应用** — Tauri macOS 构建支持
- [ ] **解码增强 v2**完整透视变换(单应矩阵),模糊图像增强
- [ ] **PWA 支持** — Web 端可安装为 PWA,离线使用
- [ ] **跨平台 GUI** — macOS + Linux 桌面应用发布
## v1.0.0 (长期)
- [ ] **跨平台 GUI** — 完整的 Windows + macOS + Linux 桌面应用发布
- [ ] **PWA 支持** — Web 端可安装为 PWA,离线使用
- [ ] **发布到包管理器** — crates.io / winget / Homebrew / Scoop
- [ ] **WiFi 扫码自动连接** — 移动端扫码后一键连接 WiFi
- [ ] **插件系统** — 第三方编码模式扩展
- [ ] **在线服务** — 公开的 QR 码生成 API 服务(带速率限制)
## 已交付
### v0.3.0
- ✅ 格式扩展(BMP/JPEG/WebP 输出 + `OutputFormat` 枚举)
- ✅ 解码增强(旋转矫正 + 自动重试矫正流水线)
- ✅ vCard 扩展(10 字段:TITLE/URL/BDAY/NOTE/PHOTO
### v0.2.0
- ✅ 彩色 QR 码(前景色/背景色 + PNG Rgba + SVG + CLI `--fg`/`--bg`
- ✅ Logo 嵌入(PNG `imageops::overlay` + SVG base64
- ✅ CLI 编码模式(`--mode wifi/vcard/email/phone/sms`
- ✅ 批量生成(JSON/CSV 输入 → 自动编号输出)
- ✅ i18n 中英双语(i18next + react-i18next
- ✅ 前端测试(19 testsvitest + @vitest/coverage-v8
### v0.1.0
- ✅ ISO/IEC 18004 完整 QR 码生成算法
@@ -40,7 +43,7 @@ QRGen 的未来发展方向。
- ✅ Web 服务(axum + Docker alpine 17.7MB 镜像 + `/api/decode`
- ✅ QR 解码器(从零手写:定位→提取→RS纠错→模式解码,PNG/JPEG/WebP
- ✅ RS 纠错解码(伴随式→Berlekamp-Massey→Chien→Forney
-96 个 Rust 测试(72 单元 + 24 集成)
-105 个 Rust 测试(81 单元 + 24 集成)
- ✅ 前端工程化(Prettier + ESLint + vitest + husky + commitlint
- ✅ crates.io 就绪(doc comments + 元数据 + 代码示例)
- ✅ 社区规范文件(CONTRIBUTING / CODE_OF_CONDUCT / SECURITY / ROADMAP / SUPPORT
+48 -14
View File
@@ -55,6 +55,10 @@ struct Args {
#[arg(long)]
logo: Option<String>,
/// 输出图像格式 [png/bmp/jpeg/webp] [default: png]
#[arg(short = 'f', long, default_value = "png")]
format: String,
// ---- 编码模式参数 ----
/// 编码模式 [text/url/wifi/vcard/email/phone/sms/batch]
#[arg(long)]
@@ -96,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>,
@@ -199,24 +223,27 @@ fn main() -> anyhow::Result<()> {
.to_lowercase();
match ext.as_str() {
"png" => {
let bytes = qr.to_png_bytes(args.size, logo_bytes.as_deref())?;
fs::write(path, bytes)?;
println!(
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)",
path,
qr.version.0,
qr.size(),
qr.size(),
qr.level
);
}
"svg" => {
let svg = qr.to_svg(logo_bytes.as_deref());
fs::write(path, svg)?;
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
}
_ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext),
_ => {
let fmt = qr_core::render::image::OutputFormat::from_ext(&ext)
.or_else(|| qr_core::render::image::OutputFormat::from_ext(&args.format))
.unwrap_or(qr_core::render::image::OutputFormat::Png);
let bytes = qr.to_image_bytes(args.size, logo_bytes.as_deref(), Some(fmt))?;
fs::write(path, bytes)?;
println!(
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错, {})",
path,
qr.version.0,
qr.size(),
qr.size(),
qr.level,
fmt.extension()
);
}
}
}
None => {
@@ -248,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
@@ -359,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("");
+1 -1
View File
@@ -14,7 +14,7 @@ categories.workspace = true
rust-version.workspace = true
[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
+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
}
+22 -7
View File
@@ -271,24 +271,39 @@ impl QrCode {
crate::render::ascii::render_ascii(self, invert)
}
/// 导出为 PNG 字节数据
/// 导出为图像字节数据(支持 PNG/BMP/JPEG/WebP
///
/// `module_size` 控制每个模块的像素大小(2~20),越大文件越大
/// `logo` 可选的 logo 图片字节,会在 QR 码中央叠加(建议搭配 H 级纠错)
/// `module_size` 控制每个模块的像素大小(2~20)。
/// `format` 输出格式,默认为 Png。
/// `logo` 可选的 logo 图片字节。
///
/// ```rust
/// use qr_core::qr::{QrCode, QrConfig};
///
/// let qr = QrCode::encode("PNG test", QrConfig::default()).unwrap();
/// let bytes = qr.to_png_bytes(4, None).unwrap();
/// std::fs::write("test.png", &bytes).unwrap();
/// let qr = QrCode::encode("test", QrConfig::default()).unwrap();
/// let bytes = qr.to_image_bytes(4, None, None).unwrap();
/// ```
pub fn to_image_bytes(
&self,
module_size: u8,
logo: Option<&[u8]>,
format: Option<crate::render::image::OutputFormat>,
) -> Result<Vec<u8>, image::ImageError> {
crate::render::image::render_image(
self,
module_size,
format.unwrap_or(crate::render::image::OutputFormat::Png),
logo,
)
}
/// 导出为 PNG 字节数据(便捷方法,兼容旧 API)
pub fn to_png_bytes(
&self,
module_size: u8,
logo: Option<&[u8]>,
) -> Result<Vec<u8>, image::ImageError> {
crate::render::png::render_png(self, module_size, logo)
self.to_image_bytes(module_size, logo, None)
}
}
@@ -1,6 +1,62 @@
//! QR 码图像渲染(支持 PNG/BMP/JPEG/WebP
//!
//! 使用 `image` crate 将 QR 模块矩阵渲染为像素缓冲区,可选叠加 Logo。
use crate::qr::QrCode;
use image::{imageops, ImageBuffer, Rgba, RgbaImage};
/// 输出图像格式
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Png,
Bmp,
Jpeg,
WebP,
}
impl OutputFormat {
/// 转为 `image` crate 的格式枚举
fn to_image_format(self) -> image::ImageFormat {
match self {
Self::Png => image::ImageFormat::Png,
Self::Bmp => image::ImageFormat::Bmp,
Self::Jpeg => image::ImageFormat::Jpeg,
Self::WebP => image::ImageFormat::WebP,
}
}
/// 文件扩展名(不含点)
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Bmp => "bmp",
Self::Jpeg => "jpeg",
Self::WebP => "webp",
}
}
/// MIME 类型
pub fn mime(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Bmp => "image/bmp",
Self::Jpeg => "image/jpeg",
Self::WebP => "image/webp",
}
}
/// 从扩展名解析
pub fn from_ext(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_str() {
"png" => Some(Self::Png),
"bmp" => Some(Self::Bmp),
"jpeg" | "jpg" => Some(Self::Jpeg),
"webp" => Some(Self::WebP),
_ => None,
}
}
}
fn fill_module(
img: &mut RgbaImage,
x: u32,
@@ -24,32 +80,27 @@ fn fill_module(
}
}
/// 在 QR 码 PNG 缓冲区中央叠加 logo
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}"))?;
let logo = logo.to_rgba8();
let img_w = img.width();
let img_h = img.height();
// Logo 边长 = min(图像边长 * pct, 实际 QR 区域 * pct)
let logo_size = (img_w.min(img_h) as f32 * logo_size_pct) as u32;
if logo_size < 4 {
return Ok(()); // 太小,跳过
return Ok(());
}
let resized = imageops::resize(&logo, logo_size, logo_size, imageops::FilterType::Lanczos3);
let x = (img_w - logo_size) / 2;
let y = (img_h - logo_size) / 2;
imageops::overlay(img, &resized, x as i64, y as i64);
Ok(())
}
pub fn render_png(
/// 渲染 QR 码到图像字节(支持 PNG/BMP/JPEG/WebP
pub fn render_image(
qr: &QrCode,
module_size: u8,
format: OutputFormat,
logo: Option<&[u8]>,
) -> Result<Vec<u8>, image::ImageError> {
let matrix_size = qr.size() as u32;
@@ -72,7 +123,6 @@ pub fn render_png(
} else {
false
};
fill_module(
&mut img,
x,
@@ -85,13 +135,14 @@ pub fn render_png(
}
}
// Logo 叠加
if let Some(logo_data) = logo {
// 忽略 logo 叠加错误(logo 有损不影响 QR 主体)
let _ = overlay_logo(&mut img, logo_data, 0.25);
}
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
img.write_to(
&mut std::io::Cursor::new(&mut buf),
format.to_image_format(),
)?;
Ok(buf)
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub mod ascii;
pub mod png;
pub mod image;
pub mod svg;
+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]
@@ -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": "收件人",
+5
View File
@@ -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 -2
View File
@@ -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 链接 */
+7 -6
View File
@@ -70,12 +70,13 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
if params.fmt == "svg" {
let svg = qr.to_svg(None);
([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response()
} else {
match qr.to_png_bytes(params.size, None) {
Ok(b) => ([(header::CONTENT_TYPE, "image/png")], b).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
return ([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response();
}
let fmt = qr_core::render::image::OutputFormat::from_ext(&params.fmt)
.unwrap_or(qr_core::render::image::OutputFormat::Png);
match qr.to_image_bytes(params.size, None, Some(fmt)) {
Ok(b) => ([(header::CONTENT_TYPE, fmt.mime())], b).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}