refactor: CLI P0-P2 全面改进
P0: - 子命令重构: qrgen encode / qrgen decode - Shell 补全: --generate-completions bash/zsh/fish/pwsh/elvish - -v 改为 -V (version), Cli::version 继承 Cargo.toml P1: - stdin 管道: encode/decode 支持 - 从 stdin 读取 - 退出码: 0=成功, 1=输入错误, 2=系统错误 - 错误消息: bail! 宏 + 上下文信息 P2: - 进度指示: batch 模式使用 indicatif 进度条 - --version 自动生成 新增依赖: clap_complete, indicatif, image (direct)
This commit is contained in:
+234
-437
@@ -1,513 +1,310 @@
|
||||
use clap::Parser;
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::{generate, Shell};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
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::io::{self, Read};
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
// ──────────────────── 结构定义 ────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "qrgen",
|
||||
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现"
|
||||
version,
|
||||
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现",
|
||||
after_help = "示例:\n qrgen encode \"Hello\" -o qr.png\n qrgen encode --mode wifi --ssid MyWiFi --password pass123\n qrgen decode qr.png\n echo \"Hello\" | qrgen encode -\n\n补全:\n qrgen --generate-completions bash > /usr/share/bash-completion/completions/qrgen"
|
||||
)]
|
||||
struct Args {
|
||||
/// 快捷编码内容
|
||||
content: Option<String>,
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
#[arg(long, value_name = "SHELL", value_parser = ["bash", "zsh", "fish", "powershell", "elvish"])]
|
||||
generate_completions: Option<String>,
|
||||
}
|
||||
|
||||
/// 解码图片文件 (PNG/JPEG/WebP)
|
||||
#[arg(short = 'd', long)]
|
||||
decode: Option<String>,
|
||||
#[derive(Subcommand)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Command {
|
||||
/// 编码:文本 → QR 码
|
||||
Encode {
|
||||
/// 要编码的内容(传 `-` 从 stdin 读取)
|
||||
#[arg(default_value = "-")]
|
||||
content: String,
|
||||
/// 输出文件;不指定则终端 ASCII
|
||||
#[arg(short = 'o', long)]
|
||||
output: Option<String>,
|
||||
#[command(flatten)]
|
||||
opts: EncodeOpts,
|
||||
},
|
||||
/// 解码:QR 码图片 → 文本
|
||||
Decode {
|
||||
/// 图片文件路径(传 `-` 从 stdin 读取)
|
||||
#[arg(default_value = "-")]
|
||||
file: 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>,
|
||||
|
||||
/// 输出图像格式 [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)]
|
||||
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>,
|
||||
|
||||
/// 职位 (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>,
|
||||
|
||||
/// 主题 (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(clap::Args, Clone)]
|
||||
struct EncodeOpts {
|
||||
#[arg(short = 'l', long, default_value = "M")] level: String,
|
||||
#[arg(short = 'V', long, value_parser = clap::value_parser!(u8).range(1..=40))] version: Option<u8>,
|
||||
#[arg(short = 's', long, default_value = "4")] size: u8,
|
||||
#[arg(short = 'm', long, default_value = "4")] margin: u8,
|
||||
#[arg(long)] fg: Option<String>,
|
||||
#[arg(long)] bg: Option<String>,
|
||||
#[arg(long)] logo: Option<String>,
|
||||
#[arg(short = 'f', long, default_value = "png")] format: String,
|
||||
#[arg(long)] mode: Option<String>,
|
||||
// WiFi
|
||||
#[arg(long)] ssid: Option<String>,
|
||||
#[arg(long)] password: Option<String>,
|
||||
#[arg(long, default_value = "WPA")] encryption: String,
|
||||
#[arg(long)] hidden: bool,
|
||||
// vCard
|
||||
#[arg(long)] name: Option<String>,
|
||||
#[arg(long)] phone: Option<String>,
|
||||
#[arg(long)] email: Option<String>,
|
||||
#[arg(long)] company: Option<String>,
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] address: Option<String>,
|
||||
#[arg(long = "vcard-url")] vcard_url: Option<String>,
|
||||
#[arg(long)] birthday: Option<String>,
|
||||
#[arg(long)] note: Option<String>,
|
||||
#[arg(long)] photo: Option<String>,
|
||||
// Email
|
||||
#[arg(long)] to: Option<String>,
|
||||
#[arg(long)] subject: Option<String>,
|
||||
#[arg(long)] body: Option<String>,
|
||||
// Phone/SMS
|
||||
#[arg(long)] number: Option<String>,
|
||||
#[arg(long)] message: Option<String>,
|
||||
// URL
|
||||
#[arg(long)] url: Option<String>,
|
||||
// Batch
|
||||
#[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>,
|
||||
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);
|
||||
struct E { msg: String, code: i32 }
|
||||
impl E {
|
||||
fn new(m: impl Into<String>, c: i32) -> Self { Self { msg: m.into(), code: c } }
|
||||
fn exit(&self) -> ! { eprintln!("qrgen: {}", self.msg); process::exit(self.code); }
|
||||
}
|
||||
impl From<std::io::Error> for E {
|
||||
fn from(e: std::io::Error) -> Self { E::new(format!("IO 错误: {e}"), 2) }
|
||||
}
|
||||
impl From<image::ImageError> for E {
|
||||
fn from(e: image::ImageError) -> Self { E::new(format!("图像错误: {e}"), 2) }
|
||||
}
|
||||
macro_rules! bail {
|
||||
($msg:expr, $code:expr) => { return Err(E::new($msg, $code)) };
|
||||
($fmt:expr, $code:expr, $($arg:tt)*) => { return Err(E::new(format!($fmt, $($arg)*), $code)) };
|
||||
}
|
||||
|
||||
// ──────────────────── 入口 ────────────────────
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Some(s) = cli.generate_completions {
|
||||
if let Ok(sh) = s.parse::<Shell>() {
|
||||
generate(sh, &mut Cli::command(), "qrgen", &mut io::stdout());
|
||||
return;
|
||||
}
|
||||
}
|
||||
let r = match cli.command {
|
||||
Command::Encode { content, output, opts } => cmd_encode(&content, &output, &opts),
|
||||
Command::Decode { file } => cmd_decode(&file),
|
||||
};
|
||||
if let Err(e) = r { e.exit(); }
|
||||
}
|
||||
|
||||
if let Some(ref batch_file) = args.batch {
|
||||
return do_batch(batch_file, &args);
|
||||
}
|
||||
// ──────────────────── I/O 辅助 ────────────────────
|
||||
|
||||
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}"))?;
|
||||
fn stdin_bytes() -> Result<Vec<u8>, E> {
|
||||
let mut b = Vec::new();
|
||||
io::stdin().read_to_end(&mut b).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
|
||||
Ok(b)
|
||||
}
|
||||
fn stdin_text() -> Result<String, E> {
|
||||
let mut s = String::new();
|
||||
io::stdin().read_to_string(&mut s).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
|
||||
let t = s.trim().to_string();
|
||||
if t.is_empty() { bail!("stdin 为空", 2); }
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
// ──────────────────── 编码 ────────────────────
|
||||
|
||||
fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Result<(), E> {
|
||||
let text = if content == "-" { stdin_text()? } else { content.to_string() };
|
||||
let final_text = if let Some(m) = &opts.mode {
|
||||
build_mode(m, opts, &text)?
|
||||
} else if let Some(ref bf) = opts.batch {
|
||||
return do_batch(bf, opts);
|
||||
} else { text };
|
||||
|
||||
let level = parse_level(&opts.level)?;
|
||||
let logo = opts.logo.as_ref().map(std::fs::read).transpose()
|
||||
.map_err(|e| E::new(format!("无法读取 logo: {e}"), 2))?;
|
||||
|
||||
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(),
|
||||
level, version: opts.version.map(VersionMode::Fixed).unwrap_or(VersionMode::Auto),
|
||||
margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone(),
|
||||
};
|
||||
let qr = QrCode::encode(&final_text, config).map_err(|e| E::new(format!("编码失败: {e}"), 1))?;
|
||||
|
||||
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 output {
|
||||
Some(p) => {
|
||||
check_path(p)?;
|
||||
let ext = Path::new(p).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
|
||||
match ext.as_str() {
|
||||
"svg" => {
|
||||
let svg = qr.to_svg(logo_bytes.as_deref());
|
||||
fs::write(path, svg)?;
|
||||
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
|
||||
}
|
||||
"svg" => { std::fs::write(p, qr.to_svg(logo.as_deref()))?; eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0); }
|
||||
_ => {
|
||||
let fmt = qr_core::render::image::OutputFormat::from_ext(&ext)
|
||||
.or_else(|| qr_core::render::image::OutputFormat::from_ext(&args.format))
|
||||
.or_else(|| qr_core::render::image::OutputFormat::from_ext(&opts.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()
|
||||
);
|
||||
std::fs::write(p, qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt))?)?;
|
||||
eprintln!("已生成: {p} (版本 {}, {}×{}, {:?}, {})", qr.version.0, qr.size(), qr.size(), qr.level, fmt.extension());
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("{}", qr.to_ascii(args.invert));
|
||||
}
|
||||
None => { println!("{}", qr.to_ascii(false)); }
|
||||
}
|
||||
|
||||
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,
|
||||
))
|
||||
fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String, E> {
|
||||
match mode {
|
||||
"wifi" => {
|
||||
let s = opts.ssid.as_deref().ok_or_else(|| E::new("WiFi 模式需要 --ssid", 1))?;
|
||||
Ok(text_builder::build_wifi_text(s, opts.password.as_deref().unwrap_or(""), &opts.encryption, opts.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(""),
|
||||
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(""),
|
||||
"vcard" => Ok(text_builder::build_vcard_text(
|
||||
opts.name.as_deref().unwrap_or(""), opts.phone.as_deref().unwrap_or(""),
|
||||
opts.email.as_deref().unwrap_or(""), opts.company.as_deref().unwrap_or(""),
|
||||
opts.address.as_deref().unwrap_or(""), opts.title.as_deref().unwrap_or(""),
|
||||
opts.vcard_url.as_deref().unwrap_or(""), opts.birthday.as_deref().unwrap_or(""),
|
||||
opts.note.as_deref().unwrap_or(""), opts.photo.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(""),
|
||||
))
|
||||
"email" => {
|
||||
let t = opts.to.as_deref().ok_or_else(|| E::new("Email 模式需要 --to", 1))?;
|
||||
Ok(text_builder::build_email_text(t, opts.subject.as_deref().unwrap_or(""), opts.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("text") => args
|
||||
.content
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("文本模式需要提供编码内容")),
|
||||
Some(m) => anyhow::bail!("未知模式: {m}。支持 text/url/wifi/vcard/email/phone/sms/batch"),
|
||||
None => args
|
||||
.content
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("请提供编码内容或使用 --mode 指定模式")),
|
||||
"phone" => Ok(text_builder::build_phone_text(opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?)),
|
||||
"sms" => Ok(text_builder::build_sms_text(
|
||||
opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?,
|
||||
opts.message.as_deref().unwrap_or(""),
|
||||
)),
|
||||
"url" => opts.url.clone().ok_or_else(|| E::new("URL 模式需要 --url", 1)),
|
||||
"text" => Ok(fb.to_string()),
|
||||
_m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch", 1),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// ──────────────────── 解码 ────────────────────
|
||||
|
||||
fn cmd_decode(file: &str) -> Result<(), E> {
|
||||
let bytes = if file == "-" { stdin_bytes()? } else { std::fs::read(file)? };
|
||||
let r = qr_core::decoder::decode_image(&bytes).map_err(|e| E::new(format!("解码失败: {e}"), 1))?;
|
||||
println!("{}", r.text);
|
||||
eprintln!("版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字", r.version, r.level, r.mask, r.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))?;
|
||||
// ──────────────────── 批量 ────────────────────
|
||||
|
||||
fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> {
|
||||
let input = std::fs::read_to_string(file)
|
||||
.map_err(|e| E::new(format!("无法读取批量文件 '{file}': {e}"), 2))?;
|
||||
let entries: Vec<BatchEntry> = serde_json::from_str(&input)
|
||||
.or_else(|_| parse_csv(&input))
|
||||
.map_err(|e| anyhow::anyhow!("无法解析输入: {e}\n支持 JSON 数组或 CSV 格式"))?;
|
||||
.map_err(|e| E::new(format!("解析失败: {e}"), 2))?;
|
||||
let out = opts.output_dir.as_deref().unwrap_or("batch_output");
|
||||
std::fs::create_dir_all(out).map_err(|e| E::new(format!("无法创建目录 '{out}': {e}"), 2))?;
|
||||
|
||||
let out_dir = args.output_dir.as_deref().unwrap_or("batch_output");
|
||||
fs::create_dir_all(out_dir)?;
|
||||
let total = entries.len();
|
||||
let pb = ProgressBar::new(total as u64);
|
||||
pb.set_style(ProgressStyle::default_bar().template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}").unwrap());
|
||||
|
||||
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);
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let text = batch_text(e)?;
|
||||
let lvl = e.level.as_deref().map(parse_level).unwrap_or(Ok(EcLevel::M))?;
|
||||
let cfg = QrConfig { level: lvl, version: VersionMode::Auto, margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone() };
|
||||
let qr = QrCode::encode(&text, cfg).map_err(|e| E::new(e, 1))?;
|
||||
let path = format!("{out}/qr_{:04}.png", i + 1);
|
||||
std::fs::write(&path, qr.to_png_bytes(opts.size, None).map_err(|e| E::new(format!("{e}"), 2))?)?;
|
||||
pb.set_message(path.clone());
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
println!("批量生成完成: {} 个 QR 码 → {}", entries.len(), out_dir);
|
||||
pb.finish_with_message(format!("完成: {total} 个 QR → {out}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn batch_entry_to_text(entry: &BatchEntry) -> anyhow::Result<String> {
|
||||
if let Some(c) = &entry.content {
|
||||
return Ok(c.clone());
|
||||
fn batch_text(e: &BatchEntry) -> Result<String, E> {
|
||||
if let Some(c) = &e.content { return Ok(c.clone()); }
|
||||
if let Some(u) = &e.url { return Ok(u.clone()); }
|
||||
if let Some(s) = &e.ssid {
|
||||
return Ok(text_builder::build_wifi_text(s, e.password.as_deref().unwrap_or(""), e.encryption.as_deref().unwrap_or("WPA"), e.hidden.unwrap_or(false)));
|
||||
}
|
||||
if let Some(u) = &entry.url {
|
||||
return Ok(u.clone());
|
||||
if let Some(n) = &e.name {
|
||||
return Ok(text_builder::build_vcard_text(n, e.phone.as_deref().unwrap_or(""), e.email.as_deref().unwrap_or(""), e.company.as_deref().unwrap_or(""), e.address.as_deref().unwrap_or(""), "", "", "", "", ""));
|
||||
}
|
||||
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(t) = &e.to {
|
||||
return Ok(text_builder::build_email_text(t, e.subject.as_deref().unwrap_or(""), e.body.as_deref().unwrap_or("")));
|
||||
}
|
||||
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));
|
||||
}
|
||||
if let Some(n) = &e.number {
|
||||
if let Some(m) = &e.message { return Ok(text_builder::build_sms_text(n, m)); }
|
||||
return Ok(text_builder::build_phone_text(n));
|
||||
}
|
||||
anyhow::bail!("无法识别的条目格式")
|
||||
bail!("无法识别的条目格式", 1)
|
||||
}
|
||||
|
||||
// ──────────────────── 工具函数 ────────────────────
|
||||
|
||||
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();
|
||||
let cols: Vec<&str> = lines.next().ok_or("CSV 为空")?.split(',').map(|s| s.trim()).collect();
|
||||
let mut out = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
if line.trim().is_empty() { continue; }
|
||||
let v: Vec<String> = line.split(',').map(|s| s.trim().trim_matches('"').to_string()).collect();
|
||||
let (mut c,mut l,mut ss,mut pw,mut en,mut hi,mut na,mut ph,mut em,mut co,mut ad,mut to_,mut su,mut bo,mut nu,mut ms,mut ur) =
|
||||
(None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None);
|
||||
for (i, col) in cols.iter().enumerate() {
|
||||
let val = v.get(i).cloned();
|
||||
match *col { "content"=>c=val,"level"=>l=val,"ssid"=>ss=val,"password"=>pw=val,"encryption"=>en=val,"hidden"=>hi=val.map(|x|x=="true"),"name"=>na=val,"phone"=>ph=val,"email"=>em=val,"company"=>co=val,"address"=>ad=val,"to"=>to_=val,"subject"=>su=val,"body"=>bo=val,"number"=>nu=val,"message"=>ms=val,"url"=>ur=val, _=>{} }
|
||||
}
|
||||
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,
|
||||
});
|
||||
out.push(BatchEntry{content:c,level:l,ssid:ss,password:pw,encryption:en,hidden:hi,name:na,phone:ph,email:em,company:co,address:ad,to:to_,subject:su,body:bo,number:nu,message:ms,url:ur});
|
||||
}
|
||||
Ok(entries)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> anyhow::Result<EcLevel> {
|
||||
fn parse_level(s: &str) -> Result<EcLevel, E> {
|
||||
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),
|
||||
"L"=>Ok(EcLevel::L),"M"=>Ok(EcLevel::M),"Q"=>Ok(EcLevel::Q),"H"=>Ok(EcLevel::H),
|
||||
_ => bail!("无效纠错级别: '{s}',支持 L/M/Q/H", 1),
|
||||
}
|
||||
}
|
||||
|
||||
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!("不允许包含 '..' 的路径,请使用当前目录下的文件名");
|
||||
fn check_path(p: &str) -> Result<(), E> {
|
||||
if Path::new(p).components().any(|c| matches!(c, std::path::Component::ParentDir)) {
|
||||
bail!("路径不允许包含 '..'", 2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user