feat: CLI 编码模式 + 批量生成 + text_builder
- 新增 core/src/text_builder.rs — Rust 版文本构造(WiFi/vCard/Email/Phone/SMS) - CLI 新增 --mode 模式参数(wifi/vcard/email/phone/sms/url/batch) - CLI 新增 --ssid/--password/--name/--phone/--to 等模式专属参数 - CLI 新增 --batch <file> 批量生成(JSON 数组 / CSV) - 批量支持自动检测 JSON/CSV 格式并自动编号输出 - 新增 6 个 text_builder 单元测试(80 tests total)
This commit is contained in:
Generated
+2
@@ -2689,6 +2689,8 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"qr-core",
|
"qr-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ path = "src/main.rs"
|
|||||||
qr-core = { path = "../core" }
|
qr-core = { path = "../core" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
+361
-44
@@ -1,6 +1,9 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||||
|
use qr_core::text_builder;
|
||||||
use qr_core::version::EcLevel;
|
use qr_core::version::EcLevel;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -9,10 +12,10 @@ use std::path::Path;
|
|||||||
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现"
|
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现"
|
||||||
)]
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// 要编码的内容(编码模式)
|
/// 快捷编码内容
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
|
||||||
/// 解码图片文件 (PNG/JPEG/WebP),与编码模式互斥
|
/// 解码图片文件 (PNG/JPEG/WebP)
|
||||||
#[arg(short = 'd', long)]
|
#[arg(short = 'd', long)]
|
||||||
decode: Option<String>,
|
decode: Option<String>,
|
||||||
|
|
||||||
@@ -40,41 +43,137 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
invert: bool,
|
invert: bool,
|
||||||
|
|
||||||
/// 前景色 "#RRGGBB" [default: "#000000"]
|
/// 前景色 "#RRGGBB"
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
fg: Option<String>,
|
fg: Option<String>,
|
||||||
|
|
||||||
/// 背景色 "#RRGGBB" [default: "#FFFFFF"]
|
/// 背景色 "#RRGGBB"
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bg: Option<String>,
|
bg: Option<String>,
|
||||||
|
|
||||||
|
/// Logo 图片文件
|
||||||
|
#[arg(long)]
|
||||||
|
logo: Option<String>,
|
||||||
|
|
||||||
|
// ---- 编码模式参数 ----
|
||||||
|
/// 编码模式 [text/url/wifi/vcard/email/phone/sms/batch]
|
||||||
|
#[arg(long)]
|
||||||
|
mode: Option<String>,
|
||||||
|
|
||||||
|
/// WiFi SSID
|
||||||
|
#[arg(long)]
|
||||||
|
ssid: Option<String>,
|
||||||
|
|
||||||
|
/// WiFi 密码
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// WiFi 加密方式 [default: WPA]
|
||||||
|
#[arg(long, default_value = "WPA")]
|
||||||
|
encryption: String,
|
||||||
|
|
||||||
|
/// 隐藏 WiFi 网络
|
||||||
|
#[arg(long)]
|
||||||
|
hidden: bool,
|
||||||
|
|
||||||
|
/// 姓名 (vCard)
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
|
||||||
|
/// 电话 (vCard)
|
||||||
|
#[arg(long)]
|
||||||
|
phone: Option<String>,
|
||||||
|
|
||||||
|
/// 邮箱 (vCard)
|
||||||
|
#[arg(long)]
|
||||||
|
email: Option<String>,
|
||||||
|
|
||||||
|
/// 公司 (vCard)
|
||||||
|
#[arg(long)]
|
||||||
|
company: Option<String>,
|
||||||
|
|
||||||
|
/// 地址 (vCard)
|
||||||
|
#[arg(long)]
|
||||||
|
address: Option<String>,
|
||||||
|
|
||||||
|
/// 收件人 (Email)
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
|
||||||
|
/// 主题 (Email)
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
|
||||||
|
/// 正文 (Email)
|
||||||
|
#[arg(long)]
|
||||||
|
body: Option<String>,
|
||||||
|
|
||||||
|
/// 电话号码 (Phone/SMS)
|
||||||
|
#[arg(long)]
|
||||||
|
number: Option<String>,
|
||||||
|
|
||||||
|
/// 短信内容 (SMS)
|
||||||
|
#[arg(long)]
|
||||||
|
message: Option<String>,
|
||||||
|
|
||||||
|
/// URL 链接
|
||||||
|
#[arg(long)]
|
||||||
|
url: Option<String>,
|
||||||
|
|
||||||
|
/// 批量输入文件 (JSON/CSV)
|
||||||
|
#[arg(long)]
|
||||||
|
batch: Option<String>,
|
||||||
|
|
||||||
|
/// 批量输出目录
|
||||||
|
#[arg(long)]
|
||||||
|
output_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BatchEntry {
|
||||||
|
content: Option<String>,
|
||||||
|
level: Option<String>,
|
||||||
|
ssid: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
encryption: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
hidden: Option<bool>,
|
||||||
|
name: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
company: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
subject: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
number: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// 解码模式
|
|
||||||
if let Some(path) = args.decode {
|
if let Some(path) = args.decode {
|
||||||
return do_decode(&path);
|
return do_decode(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编码模式
|
if let Some(ref batch_file) = args.batch {
|
||||||
let content = args
|
return do_batch(batch_file, &args);
|
||||||
.content
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("请提供编码内容,或使用 --decode <文件> 解码图片"))?;
|
|
||||||
do_encode(content, &args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
let text = build_text_from_args(&args)?;
|
||||||
let level = match args.level.to_uppercase().as_str() {
|
let level = parse_level(&args.level)?;
|
||||||
"L" => EcLevel::L,
|
let logo_bytes = args
|
||||||
"M" => EcLevel::M,
|
.logo
|
||||||
"Q" => EcLevel::Q,
|
.as_ref()
|
||||||
"H" => EcLevel::H,
|
.map(|p| fs::read(p))
|
||||||
_ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", args.level),
|
.transpose()
|
||||||
};
|
.map_err(|e| anyhow::anyhow!("无法读取 logo 文件: {e}"))?;
|
||||||
|
|
||||||
let version = match args.version {
|
let config = QrConfig {
|
||||||
|
level,
|
||||||
|
version: match args.version {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
if !(1..=40).contains(&v) {
|
if !(1..=40).contains(&v) {
|
||||||
anyhow::bail!("无效版本号: {}。支持 1-40", v);
|
anyhow::bail!("无效版本号: {}。支持 1-40", v);
|
||||||
@@ -82,29 +181,18 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
VersionMode::Fixed(v)
|
VersionMode::Fixed(v)
|
||||||
}
|
}
|
||||||
None => VersionMode::Auto,
|
None => VersionMode::Auto,
|
||||||
};
|
},
|
||||||
|
|
||||||
let config = QrConfig {
|
|
||||||
level,
|
|
||||||
version,
|
|
||||||
margin: args.margin,
|
margin: args.margin,
|
||||||
fg_color: args.fg.clone(),
|
fg_color: args.fg.clone(),
|
||||||
bg_color: args.bg.clone(),
|
bg_color: args.bg.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
||||||
|
|
||||||
match &args.output {
|
match &args.output {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let path_obj = Path::new(path);
|
check_path(path)?;
|
||||||
if path_obj
|
let ext = Path::new(path)
|
||||||
.components()
|
|
||||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
|
||||||
{
|
|
||||||
anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名");
|
|
||||||
}
|
|
||||||
|
|
||||||
let ext = path_obj
|
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
@@ -112,8 +200,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
match ext.as_str() {
|
match ext.as_str() {
|
||||||
"png" => {
|
"png" => {
|
||||||
let bytes = qr.to_png_bytes(args.size, None)?;
|
let bytes = qr.to_png_bytes(args.size, logo_bytes.as_deref())?;
|
||||||
std::fs::write(path, bytes)?;
|
fs::write(path, bytes)?;
|
||||||
println!(
|
println!(
|
||||||
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)",
|
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)",
|
||||||
path,
|
path,
|
||||||
@@ -124,8 +212,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"svg" => {
|
"svg" => {
|
||||||
let svg = qr.to_svg(None);
|
let svg = qr.to_svg(logo_bytes.as_deref());
|
||||||
std::fs::write(path, svg)?;
|
fs::write(path, svg)?;
|
||||||
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
|
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
|
||||||
}
|
}
|
||||||
_ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext),
|
_ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext),
|
||||||
@@ -139,12 +227,71 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_text_from_args(args: &Args) -> anyhow::Result<String> {
|
||||||
|
match args.mode.as_deref() {
|
||||||
|
Some("wifi") => {
|
||||||
|
let ssid = args
|
||||||
|
.ssid
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?;
|
||||||
|
let pwd = args.password.as_deref().unwrap_or("");
|
||||||
|
Ok(text_builder::build_wifi_text(
|
||||||
|
ssid,
|
||||||
|
pwd,
|
||||||
|
&args.encryption,
|
||||||
|
args.hidden,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("vcard") => Ok(text_builder::build_vcard_text(
|
||||||
|
args.name.as_deref().unwrap_or(""),
|
||||||
|
args.phone.as_deref().unwrap_or(""),
|
||||||
|
args.email.as_deref().unwrap_or(""),
|
||||||
|
args.company.as_deref().unwrap_or(""),
|
||||||
|
args.address.as_deref().unwrap_or(""),
|
||||||
|
)),
|
||||||
|
Some("email") => {
|
||||||
|
let to = args
|
||||||
|
.to
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?;
|
||||||
|
Ok(text_builder::build_email_text(
|
||||||
|
to,
|
||||||
|
args.subject.as_deref().unwrap_or(""),
|
||||||
|
args.body.as_deref().unwrap_or(""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("phone") => {
|
||||||
|
let num = args
|
||||||
|
.number
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("电话模式需要 --number"))?;
|
||||||
|
Ok(text_builder::build_phone_text(num))
|
||||||
|
}
|
||||||
|
Some("sms") => {
|
||||||
|
let num = args
|
||||||
|
.number
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("短信模式需要 --number"))?;
|
||||||
|
Ok(text_builder::build_sms_text(
|
||||||
|
num,
|
||||||
|
args.message.as_deref().unwrap_or(""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("url") => args
|
||||||
|
.url
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")),
|
||||||
|
Some(m) => anyhow::bail!("未知模式: {m}。支持 text/url/wifi/vcard/email/phone/sms/batch"),
|
||||||
|
None => args
|
||||||
|
.content
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("请提供编码内容或使用 --mode 指定模式")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn do_decode(path: &str) -> anyhow::Result<()> {
|
fn do_decode(path: &str) -> anyhow::Result<()> {
|
||||||
let bytes =
|
let bytes = fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?;
|
||||||
std::fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?;
|
|
||||||
|
|
||||||
let result = qr_core::decoder::decode_image(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
let result = qr_core::decoder::decode_image(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
println!("解码成功:");
|
println!("解码成功:");
|
||||||
println!(" 文本: {}", result.text);
|
println!(" 文本: {}", result.text);
|
||||||
println!(" 版本: {}", result.version);
|
println!(" 版本: {}", result.version);
|
||||||
@@ -153,6 +300,176 @@ fn do_decode(path: &str) -> anyhow::Result<()> {
|
|||||||
if result.errors_corrected > 0 {
|
if result.errors_corrected > 0 {
|
||||||
println!(" 纠正错误: {} 码字", result.errors_corrected);
|
println!(" 纠正错误: {} 码字", result.errors_corrected);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_batch(file: &str, args: &Args) -> anyhow::Result<()> {
|
||||||
|
let input = fs::read_to_string(file)
|
||||||
|
.map_err(|e| anyhow::anyhow!("无法读取批量文件 '{}': {}", file, e))?;
|
||||||
|
|
||||||
|
let entries: Vec<BatchEntry> = serde_json::from_str(&input)
|
||||||
|
.or_else(|_| parse_csv(&input))
|
||||||
|
.map_err(|e| anyhow::anyhow!("无法解析输入: {e}\n支持 JSON 数组或 CSV 格式"))?;
|
||||||
|
|
||||||
|
let out_dir = args.output_dir.as_deref().unwrap_or("batch_output");
|
||||||
|
fs::create_dir_all(out_dir)?;
|
||||||
|
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
let text = batch_entry_to_text(entry)?;
|
||||||
|
let level = entry
|
||||||
|
.level
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_level)
|
||||||
|
.unwrap_or(Ok(EcLevel::M))?;
|
||||||
|
|
||||||
|
let config = QrConfig {
|
||||||
|
level,
|
||||||
|
version: VersionMode::Auto,
|
||||||
|
margin: args.margin,
|
||||||
|
fg_color: args.fg.clone(),
|
||||||
|
bg_color: args.bg.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let path = format!("{}/qr_{:04}.png", out_dir, i + 1);
|
||||||
|
let bytes = qr.to_png_bytes(args.size, None)?;
|
||||||
|
fs::write(&path, bytes)?;
|
||||||
|
println!("[{}/{}] {}", i + 1, entries.len(), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("批量生成完成: {} 个 QR 码 → {}", entries.len(), out_dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_entry_to_text(entry: &BatchEntry) -> anyhow::Result<String> {
|
||||||
|
if let Some(c) = &entry.content {
|
||||||
|
return Ok(c.clone());
|
||||||
|
}
|
||||||
|
if let Some(u) = &entry.url {
|
||||||
|
return Ok(u.clone());
|
||||||
|
}
|
||||||
|
if let Some(s) = &entry.ssid {
|
||||||
|
let p = entry.password.as_deref().unwrap_or("");
|
||||||
|
let e = entry.encryption.as_deref().unwrap_or("WPA");
|
||||||
|
let h = entry.hidden.unwrap_or(false);
|
||||||
|
return Ok(text_builder::build_wifi_text(s, p, e, h));
|
||||||
|
}
|
||||||
|
if let Some(n) = &entry.name {
|
||||||
|
let ph = entry.phone.as_deref().unwrap_or("");
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if let Some(t) = &entry.to {
|
||||||
|
let s = entry.subject.as_deref().unwrap_or("");
|
||||||
|
let b = entry.body.as_deref().unwrap_or("");
|
||||||
|
return Ok(text_builder::build_email_text(t, s, b));
|
||||||
|
}
|
||||||
|
if let Some(n) = &entry.number {
|
||||||
|
if let Some(m) = &entry.message {
|
||||||
|
return Ok(text_builder::build_sms_text(n, m));
|
||||||
|
}
|
||||||
|
return Ok(text_builder::build_phone_text(n));
|
||||||
|
}
|
||||||
|
anyhow::bail!("无法识别的条目格式")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_csv(input: &str) -> Result<Vec<BatchEntry>, String> {
|
||||||
|
let mut lines = input.lines();
|
||||||
|
let header = lines.next().ok_or("CSV 为空")?;
|
||||||
|
let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for line in lines {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let values: Vec<String> = line
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().trim_matches('"').to_string())
|
||||||
|
.collect();
|
||||||
|
let mut content = None;
|
||||||
|
let mut level = None;
|
||||||
|
let mut ssid = None;
|
||||||
|
let mut password = None;
|
||||||
|
let mut encryption = None;
|
||||||
|
let mut hidden = None;
|
||||||
|
let mut name = None;
|
||||||
|
let mut phone = None;
|
||||||
|
let mut email = None;
|
||||||
|
let mut company = None;
|
||||||
|
let mut address = None;
|
||||||
|
let mut to = None;
|
||||||
|
let mut subject = None;
|
||||||
|
let mut body = None;
|
||||||
|
let mut number = None;
|
||||||
|
let mut message = None;
|
||||||
|
let mut url = None;
|
||||||
|
|
||||||
|
for (i, col) in columns.iter().enumerate() {
|
||||||
|
let val = values.get(i).cloned();
|
||||||
|
match *col {
|
||||||
|
"content" => content = val,
|
||||||
|
"level" => level = val,
|
||||||
|
"ssid" => ssid = val,
|
||||||
|
"password" => password = val,
|
||||||
|
"encryption" => encryption = val,
|
||||||
|
"hidden" => hidden = val.map(|v| v == "true"),
|
||||||
|
"name" => name = val,
|
||||||
|
"phone" => phone = val,
|
||||||
|
"email" => email = val,
|
||||||
|
"company" => company = val,
|
||||||
|
"address" => address = val,
|
||||||
|
"to" => to = val,
|
||||||
|
"subject" => subject = val,
|
||||||
|
"body" => body = val,
|
||||||
|
"number" => number = val,
|
||||||
|
"message" => message = val,
|
||||||
|
"url" => url = val,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(BatchEntry {
|
||||||
|
content,
|
||||||
|
level,
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
encryption,
|
||||||
|
hidden,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
company,
|
||||||
|
address,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
number,
|
||||||
|
message,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_level(s: &str) -> anyhow::Result<EcLevel> {
|
||||||
|
match s.to_uppercase().as_str() {
|
||||||
|
"L" => Ok(EcLevel::L),
|
||||||
|
"M" => Ok(EcLevel::M),
|
||||||
|
"Q" => Ok(EcLevel::Q),
|
||||||
|
"H" => Ok(EcLevel::H),
|
||||||
|
_ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_path(path: &str) -> anyhow::Result<()> {
|
||||||
|
let path_obj = Path::new(path);
|
||||||
|
if path_obj
|
||||||
|
.components()
|
||||||
|
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||||
|
{
|
||||||
|
anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ pub mod encoder;
|
|||||||
pub mod matrix;
|
pub mod matrix;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
pub mod text_builder;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|||||||
+11
-9
@@ -26,8 +26,7 @@ fn fill_module(
|
|||||||
|
|
||||||
/// 在 QR 码 PNG 缓冲区中央叠加 logo
|
/// 在 QR 码 PNG 缓冲区中央叠加 logo
|
||||||
fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), String> {
|
fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), String> {
|
||||||
let logo =
|
let logo = image::load_from_memory(logo_bytes).map_err(|e| format!("Logo 加载失败: {e}"))?;
|
||||||
image::load_from_memory(logo_bytes).map_err(|e| format!("Logo 加载失败: {e}"))?;
|
|
||||||
let logo = logo.to_rgba8();
|
let logo = logo.to_rgba8();
|
||||||
|
|
||||||
let img_w = img.width();
|
let img_w = img.width();
|
||||||
@@ -39,12 +38,7 @@ fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> R
|
|||||||
return Ok(()); // 太小,跳过
|
return Ok(()); // 太小,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
let resized = imageops::resize(
|
let resized = imageops::resize(&logo, logo_size, logo_size, imageops::FilterType::Lanczos3);
|
||||||
&logo,
|
|
||||||
logo_size,
|
|
||||||
logo_size,
|
|
||||||
imageops::FilterType::Lanczos3,
|
|
||||||
);
|
|
||||||
|
|
||||||
let x = (img_w - logo_size) / 2;
|
let x = (img_w - logo_size) / 2;
|
||||||
let y = (img_h - logo_size) / 2;
|
let y = (img_h - logo_size) / 2;
|
||||||
@@ -79,7 +73,15 @@ pub fn render_png(
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
fill_module(&mut img, x, y, module_size as u32, is_dark, &qr.fg_color, &qr.bg_color);
|
fill_module(
|
||||||
|
&mut img,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
module_size as u32,
|
||||||
|
is_dark,
|
||||||
|
&qr.fg_color,
|
||||||
|
&qr.bg_color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! QR 编码文本构造工具
|
||||||
|
//!
|
||||||
|
//! 集中管理各模式的文本格式(与 gui 前端 `utils/qrText.ts` 功能一致)
|
||||||
|
|
||||||
|
/// 构造 WiFi 连接字符串
|
||||||
|
pub fn build_wifi_text(ssid: &str, password: &str, encryption: &str, hidden: bool) -> String {
|
||||||
|
let h = if hidden { "H:true;" } else { "" };
|
||||||
|
format!("WIFI:T:{encryption};S:{ssid};P:{password};{h};")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造 vCard 字符串
|
||||||
|
pub fn build_vcard_text(
|
||||||
|
name: &str,
|
||||||
|
phone: &str,
|
||||||
|
email: &str,
|
||||||
|
company: &str,
|
||||||
|
address: &str,
|
||||||
|
) -> String {
|
||||||
|
format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}\nADR:{address}\nEND:VCARD")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造 mailto 链接
|
||||||
|
pub fn build_email_text(to: &str, subject: &str, body: &str) -> String {
|
||||||
|
let subject_enc = urlencoding(subject);
|
||||||
|
let body_enc = urlencoding(body);
|
||||||
|
format!("mailto:{to}?subject={subject_enc}&body={body_enc}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造电话链接
|
||||||
|
pub fn build_phone_text(number: &str) -> String {
|
||||||
|
format!("tel:{number}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造短信链接
|
||||||
|
pub fn build_sms_text(number: &str, message: &str) -> String {
|
||||||
|
format!("smsto:{number}:{message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 简易 URL 编码(仅编码特殊字符)
|
||||||
|
fn urlencoding(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
' ' => "%20".into(),
|
||||||
|
'&' => "%26".into(),
|
||||||
|
'=' => "%3D".into(),
|
||||||
|
'#' => "%23".into(),
|
||||||
|
'%' => "%25".into(),
|
||||||
|
'+' => "%2B".into(),
|
||||||
|
'\n' => "%0A".into(),
|
||||||
|
'\r' => "%0D".into(),
|
||||||
|
_ => c.to_string(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_wifi_text() {
|
||||||
|
let text = build_wifi_text("MyWiFi", "pass123", "WPA", false);
|
||||||
|
assert!(text.contains("WIFI:T:WPA;S:MyWiFi;P:pass123;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_wifi_hidden() {
|
||||||
|
let text = build_wifi_text("HiddenNet", "secret", "WPA2", true);
|
||||||
|
assert!(text.contains("H:true;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_vcard_text() {
|
||||||
|
let text = build_vcard_text("张三", "13800138000", "a@b.com", "公司", "北京");
|
||||||
|
assert!(text.contains("BEGIN:VCARD"));
|
||||||
|
assert!(text.contains("FN:张三"));
|
||||||
|
assert!(text.contains("END:VCARD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_email_text() {
|
||||||
|
let text = build_email_text("a@b.com", "Hello World", "Test body");
|
||||||
|
assert!(text.starts_with("mailto:a@b.com"));
|
||||||
|
assert!(text.contains("Hello%20World"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_phone_text() {
|
||||||
|
assert_eq!(build_phone_text("13800138000"), "tel:13800138000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_sms_text() {
|
||||||
|
let text = build_sms_text("13800138000", "Hi");
|
||||||
|
assert_eq!(text, "smsto:13800138000:Hi");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user