Files
QRGen/cli/src/main.rs
T

476 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(())
}