476 lines
14 KiB
Rust
476 lines
14 KiB
Rust
use clap::Parser;
|
||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||
use qr_core::text_builder;
|
||
use qr_core::version::EcLevel;
|
||
use serde::Deserialize;
|
||
use std::fs;
|
||
use std::path::Path;
|
||
|
||
#[derive(Parser)]
|
||
#[command(
|
||
name = "qrgen",
|
||
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现"
|
||
)]
|
||
struct Args {
|
||
/// 快捷编码内容
|
||
content: Option<String>,
|
||
|
||
/// 解码图片文件 (PNG/JPEG/WebP)
|
||
#[arg(short = 'd', long)]
|
||
decode: Option<String>,
|
||
|
||
/// 输出文件 (.png 或 .svg),不指定则输出终端 ASCII
|
||
#[arg(short = 'o', long)]
|
||
output: Option<String>,
|
||
|
||
/// 纠错级别 [L/M/Q/H] [default: M]
|
||
#[arg(short = 'l', long, default_value = "M")]
|
||
level: String,
|
||
|
||
/// 手动指定版本 (1-40),不指定则自动选择
|
||
#[arg(short = 'v', long)]
|
||
version: Option<u8>,
|
||
|
||
/// 模块像素大小(仅 PNG)[default: 4]
|
||
#[arg(short = 's', long, default_value = "4")]
|
||
size: u8,
|
||
|
||
/// 白边模块数 [default: 4]
|
||
#[arg(short = 'm', long, default_value = "4")]
|
||
margin: u8,
|
||
|
||
/// 反色(黑底白码)
|
||
#[arg(long)]
|
||
invert: bool,
|
||
|
||
/// 前景色 "#RRGGBB"
|
||
#[arg(long)]
|
||
fg: Option<String>,
|
||
|
||
/// 背景色 "#RRGGBB"
|
||
#[arg(long)]
|
||
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<()> {
|
||
let args = Args::parse();
|
||
|
||
if let Some(path) = args.decode {
|
||
return do_decode(&path);
|
||
}
|
||
|
||
if let Some(ref batch_file) = args.batch {
|
||
return do_batch(batch_file, &args);
|
||
}
|
||
|
||
let text = build_text_from_args(&args)?;
|
||
let level = parse_level(&args.level)?;
|
||
let logo_bytes = args
|
||
.logo
|
||
.as_ref()
|
||
.map(fs::read)
|
||
.transpose()
|
||
.map_err(|e| anyhow::anyhow!("无法读取 logo 文件: {e}"))?;
|
||
|
||
let config = QrConfig {
|
||
level,
|
||
version: match args.version {
|
||
Some(v) => {
|
||
if !(1..=40).contains(&v) {
|
||
anyhow::bail!("无效版本号: {}。支持 1-40", v);
|
||
}
|
||
VersionMode::Fixed(v)
|
||
}
|
||
None => 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))?;
|
||
|
||
match &args.output {
|
||
Some(path) => {
|
||
check_path(path)?;
|
||
let ext = Path::new(path)
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.unwrap_or("")
|
||
.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),
|
||
}
|
||
}
|
||
None => {
|
||
println!("{}", qr.to_ascii(args.invert));
|
||
}
|
||
}
|
||
|
||
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<()> {
|
||
let bytes = fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?;
|
||
let result = qr_core::decoder::decode_image(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||
println!("解码成功:");
|
||
println!(" 文本: {}", result.text);
|
||
println!(" 版本: {}", result.version);
|
||
println!(" 纠错级别: {:?}", result.level);
|
||
println!(" 掩码: {}", result.mask);
|
||
if result.errors_corrected > 0 {
|
||
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(())
|
||
}
|