refactor: P0-P5 全面架构重构
P1 thiserror 类型化错误: 新增 core/src/error.rs QrError 枚举, 全链 String -> QrError, 包括 EmptyInput/InvalidVersion/DataTooLong/DecodeFail 等 8 种变体 P2 text_builder Tauri 统一: 新增 build_qr_text Tauri command, 删除前端 qrText.ts, 所有 mode 组件改为 invoke 调用 Rust 端构建文本 P3 QrConfig 颜色字段移除: 从 QrConfig/QrCode 移除 fg_color/bg_color, 改为 to_svg/to_image_bytes 参数传递 P4 前端 4 项合并: Context 拆分为 StateContext+DispatchContext (H10), 新建 useModeForm 通用 hook (M11), VCardMode grid-cols-2 网格布局 (M13), persistHistory/loadHistory 迁至 utils/storage.ts (L9) P5 算法优化: MaskedView 懒计算替代 8 次 Matrix 克隆 (H9), encoding_rs 精确 Kanji Shift JIS 映射 (H12) 验证: cargo check+clippy 通过, 81+24+7 全部测试通过
This commit is contained in:
Generated
+3
@@ -3106,8 +3106,10 @@ dependencies = [
|
||||
name = "qr-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4632,6 +4634,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
|
||||
+281
-105
@@ -1,3 +1,4 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::{generate, Shell};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
@@ -49,19 +50,25 @@ enum Command {
|
||||
|
||||
#[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(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", value_parser = clap::value_parser!(u8).range(1..=20))]
|
||||
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(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, default_value = "WPA")]
|
||||
encryption: String,
|
||||
#[arg(long)] hidden: bool,
|
||||
// vCard
|
||||
#[arg(long)] name: Option<String>,
|
||||
@@ -90,31 +97,24 @@ struct EncodeOpts {
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
// ──────────────────── 错误类型 ────────────────────
|
||||
|
||||
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)) };
|
||||
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>,
|
||||
}
|
||||
|
||||
// ──────────────────── 入口 ────────────────────
|
||||
@@ -131,124 +131,238 @@ fn main() {
|
||||
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 Err(e) = r {
|
||||
eprintln!("qrgen: {:#}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── I/O 辅助 ────────────────────
|
||||
|
||||
fn stdin_bytes() -> Result<Vec<u8>, E> {
|
||||
/// 最大 stdin 读取量:10 MB
|
||||
const STDIN_MAX: u64 = 10 * 1024 * 1024;
|
||||
|
||||
fn stdin_bytes() -> Result<Vec<u8>> {
|
||||
let mut b = Vec::new();
|
||||
io::stdin().read_to_end(&mut b).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
|
||||
io::stdin()
|
||||
.take(STDIN_MAX)
|
||||
.read_to_end(&mut b)
|
||||
.with_context(|| "无法读取 stdin")?;
|
||||
Ok(b)
|
||||
}
|
||||
fn stdin_text() -> Result<String, E> {
|
||||
fn stdin_text() -> Result<String> {
|
||||
let mut s = String::new();
|
||||
io::stdin().read_to_string(&mut s).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
|
||||
io::stdin()
|
||||
.take(STDIN_MAX)
|
||||
.read_to_string(&mut s)
|
||||
.with_context(|| "无法读取 stdin")?;
|
||||
let t = s.trim().to_string();
|
||||
if t.is_empty() { bail!("stdin 为空", 2); }
|
||||
if t.is_empty() {
|
||||
bail!("stdin 为空");
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
// ──────────────────── 编码 ────────────────────
|
||||
|
||||
fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Result<(), E> {
|
||||
let text = if content == "-" { stdin_text()? } else { content.to_string() };
|
||||
fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Result<()> {
|
||||
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 };
|
||||
} 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 level: EcLevel = opts.level.parse().map_err(|e: String| anyhow::anyhow!(e))?;
|
||||
let logo = opts
|
||||
.logo
|
||||
.as_ref()
|
||||
.map(std::fs::read)
|
||||
.transpose()
|
||||
.with_context(|| "无法读取 logo")?;
|
||||
|
||||
let config = QrConfig {
|
||||
level, version: opts.version.map(VersionMode::Fixed).unwrap_or(VersionMode::Auto),
|
||||
margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone(),
|
||||
level,
|
||||
version: opts
|
||||
.version
|
||||
.map(VersionMode::Fixed)
|
||||
.unwrap_or(VersionMode::Auto),
|
||||
margin: opts.margin,
|
||||
};
|
||||
let qr = QrCode::encode(&final_text, config).map_err(|e| E::new(format!("编码失败: {e}"), 1))?;
|
||||
let qr = QrCode::encode(&final_text, config).map_err(|e| anyhow::anyhow!("编码失败: {e}"))?;
|
||||
|
||||
let fg = opts.fg.as_deref();
|
||||
let bg = opts.bg.as_deref();
|
||||
|
||||
match output {
|
||||
Some(p) => {
|
||||
check_path(p)?;
|
||||
let ext = Path::new(p).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
|
||||
let ext = Path::new(p)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if ext.is_empty() {
|
||||
eprintln!("警告: 无法从扩展名推断输出格式,回退到 PNG");
|
||||
}
|
||||
match ext.as_str() {
|
||||
"svg" => { std::fs::write(p, qr.to_svg(logo.as_deref()))?; eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0); }
|
||||
"svg" => {
|
||||
std::fs::write(p, qr.to_svg(logo.as_deref(), fg, bg))?;
|
||||
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(&opts.format))
|
||||
.unwrap_or(qr_core::render::image::OutputFormat::Png);
|
||||
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());
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("警告: 无法识别扩展名 '{}',回退到 PNG", ext);
|
||||
qr_core::render::image::OutputFormat::Png
|
||||
});
|
||||
std::fs::write(
|
||||
p,
|
||||
qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt), fg, bg)?,
|
||||
)?;
|
||||
eprintln!(
|
||||
"已生成: {p} (版本 {}, {}×{}, {:?}, {})",
|
||||
qr.version.0,
|
||||
qr.size(),
|
||||
qr.size(),
|
||||
qr.level,
|
||||
fmt.extension()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => { println!("{}", qr.to_ascii(false)); }
|
||||
None => {
|
||||
println!("{}", qr.to_ascii(false));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String, E> {
|
||||
fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String> {
|
||||
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))
|
||||
let s = opts
|
||||
.ssid
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?;
|
||||
Ok(text_builder::build_wifi_text(
|
||||
s,
|
||||
opts.password.as_deref().unwrap_or(""),
|
||||
&opts.encryption,
|
||||
opts.hidden,
|
||||
))
|
||||
}
|
||||
"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(""),
|
||||
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(""),
|
||||
)),
|
||||
"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("")))
|
||||
let t = opts
|
||||
.to
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?;
|
||||
Ok(text_builder::build_email_text(
|
||||
t,
|
||||
opts.subject.as_deref().unwrap_or(""),
|
||||
opts.body.as_deref().unwrap_or(""),
|
||||
))
|
||||
}
|
||||
"phone" => Ok(text_builder::build_phone_text(opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?)),
|
||||
"phone" => Ok(text_builder::build_phone_text(
|
||||
opts.number
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("需要 --number"))?,
|
||||
)),
|
||||
"sms" => Ok(text_builder::build_sms_text(
|
||||
opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?,
|
||||
opts.number
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("需要 --number"))?,
|
||||
opts.message.as_deref().unwrap_or(""),
|
||||
)),
|
||||
"url" => opts.url.clone().ok_or_else(|| E::new("URL 模式需要 --url", 1)),
|
||||
"url" => opts
|
||||
.url
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")),
|
||||
"text" => Ok(fb.to_string()),
|
||||
_m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch", 1),
|
||||
_m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch"),
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── 解码 ────────────────────
|
||||
|
||||
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))?;
|
||||
fn cmd_decode(file: &str) -> Result<()> {
|
||||
if file != "-" {
|
||||
check_path(file)?;
|
||||
}
|
||||
let bytes = if file == "-" {
|
||||
stdin_bytes()?
|
||||
} else {
|
||||
std::fs::read(file)?
|
||||
};
|
||||
let r = qr_core::decoder::decode_image(&bytes)
|
||||
.map_err(|e| anyhow::anyhow!("解码失败: {e}"))?;
|
||||
println!("{}", r.text);
|
||||
eprintln!("版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字", r.version, r.level, r.mask, r.errors_corrected);
|
||||
eprintln!(
|
||||
"版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字",
|
||||
r.version, r.level, r.mask, r.errors_corrected
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────── 批量 ────────────────────
|
||||
|
||||
fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> {
|
||||
fn do_batch(file: &str, opts: &EncodeOpts) -> Result<()> {
|
||||
let input = std::fs::read_to_string(file)
|
||||
.map_err(|e| E::new(format!("无法读取批量文件 '{file}': {e}"), 2))?;
|
||||
.with_context(|| format!("无法读取批量文件 '{file}'"))?;
|
||||
let entries: Vec<BatchEntry> = serde_json::from_str(&input)
|
||||
.or_else(|_| parse_csv(&input))
|
||||
.map_err(|e| E::new(format!("解析失败: {e}"), 2))?;
|
||||
.map_err(|e| anyhow::anyhow!("解析失败: {e}"))?;
|
||||
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))?;
|
||||
std::fs::create_dir_all(out)
|
||||
.with_context(|| format!("无法创建目录 '{out}'"))?;
|
||||
|
||||
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());
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
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 lvl: EcLevel = e
|
||||
.level
|
||||
.as_deref()
|
||||
.map(|s| s.parse())
|
||||
.unwrap_or(Ok(EcLevel::M))
|
||||
.map_err(|e: String| anyhow::anyhow!(e))?;
|
||||
let cfg = QrConfig {
|
||||
level: lvl,
|
||||
version: VersionMode::Auto,
|
||||
margin: opts.margin,
|
||||
};
|
||||
let qr = QrCode::encode(&text, cfg).map_err(|e| anyhow::anyhow!(e))?;
|
||||
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))?)?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
qr.to_png_bytes(opts.size, None)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
)?;
|
||||
pb.set_message(path.clone());
|
||||
pb.inc(1);
|
||||
}
|
||||
@@ -256,55 +370,117 @@ fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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()); }
|
||||
fn batch_text(e: &BatchEntry) -> Result<String> {
|
||||
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)));
|
||||
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(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(""), "", "", "", "", ""));
|
||||
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(t) = &e.to {
|
||||
return Ok(text_builder::build_email_text(t, e.subject.as_deref().unwrap_or(""), e.body.as_deref().unwrap_or("")));
|
||||
return Ok(text_builder::build_email_text(
|
||||
t,
|
||||
e.subject.as_deref().unwrap_or(""),
|
||||
e.body.as_deref().unwrap_or(""),
|
||||
));
|
||||
}
|
||||
if let Some(n) = &e.number {
|
||||
if let Some(m) = &e.message { return Ok(text_builder::build_sms_text(n, m)); }
|
||||
if let Some(m) = &e.message {
|
||||
return Ok(text_builder::build_sms_text(n, m));
|
||||
}
|
||||
return Ok(text_builder::build_phone_text(n));
|
||||
}
|
||||
bail!("无法识别的条目格式", 1)
|
||||
bail!("无法识别的条目格式")
|
||||
}
|
||||
|
||||
// ──────────────────── 工具函数 ────────────────────
|
||||
|
||||
fn parse_csv(input: &str) -> Result<Vec<BatchEntry>, String> {
|
||||
let mut lines = input.lines();
|
||||
let cols: Vec<&str> = lines.next().ok_or("CSV 为空")?.split(',').map(|s| s.trim()).collect();
|
||||
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; }
|
||||
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, _=>{} }
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
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});
|
||||
let values: Vec<String> = line
|
||||
.split(',')
|
||||
.map(|s| s.trim().trim_matches('"').to_string())
|
||||
.collect();
|
||||
|
||||
// 使用 HashMap 替代 17 字段元组解构
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (i, col) in cols.iter().enumerate() {
|
||||
if let Some(val) = values.get(i).cloned() {
|
||||
map.insert(*col, val);
|
||||
}
|
||||
}
|
||||
|
||||
let get = |k: &str| map.get(k).cloned();
|
||||
let hidden = get("hidden").map(|x| x == "true");
|
||||
|
||||
out.push(BatchEntry {
|
||||
content: get("content"),
|
||||
level: get("level"),
|
||||
ssid: get("ssid"),
|
||||
password: get("password"),
|
||||
encryption: get("encryption"),
|
||||
hidden,
|
||||
name: get("name"),
|
||||
phone: get("phone"),
|
||||
email: get("email"),
|
||||
company: get("company"),
|
||||
address: get("address"),
|
||||
to: get("to"),
|
||||
subject: get("subject"),
|
||||
body: get("body"),
|
||||
number: get("number"),
|
||||
message: get("message"),
|
||||
url: get("url"),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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),
|
||||
_ => bail!("无效纠错级别: '{s}',支持 L/M/Q/H", 1),
|
||||
fn check_path(p: &str) -> Result<()> {
|
||||
let path = Path::new(p);
|
||||
// 禁止绝对路径
|
||||
if path.is_absolute() {
|
||||
bail!("不允许使用绝对路径: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn check_path(p: &str) -> Result<(), E> {
|
||||
if Path::new(p).components().any(|c| matches!(c, std::path::Component::ParentDir)) {
|
||||
bail!("路径不允许包含 '..'", 2);
|
||||
// 禁止父目录遍历
|
||||
if path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
bail!("路径不允许包含 '..': {p}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ categories.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
|
||||
@@ -211,8 +211,11 @@ fn estimate_finder_size(gray: &[Vec<bool>], cx: usize, cy: usize) -> usize {
|
||||
}
|
||||
|
||||
/// 从二值化图像中提取 QR 布尔矩阵
|
||||
pub(crate) fn detect_and_extract(gray: &[Vec<bool>]) -> Result<DetectResult, String> {
|
||||
let finders = find_finders(gray).ok_or("未找到 QR 码定位图案")?;
|
||||
pub(crate) fn detect_and_extract(
|
||||
gray: &[Vec<bool>],
|
||||
) -> Result<DetectResult, crate::error::QrError> {
|
||||
let finders =
|
||||
find_finders(gray).ok_or_else(|| crate::error::QrError::DecodeFail("未找到 QR 码定位图案".into()))?;
|
||||
|
||||
let tl = &finders[0]; // top-left
|
||||
let tr = &finders[1]; // top-right
|
||||
@@ -222,7 +225,9 @@ pub(crate) fn detect_and_extract(gray: &[Vec<bool>]) -> Result<DetectResult, Str
|
||||
let module_size = (tl.size + tr.size) / 14; // finder = 7 modules wide
|
||||
|
||||
if module_size == 0 {
|
||||
return Err("模块大小估算为零".into());
|
||||
return Err(crate::error::QrError::DecodeFail(
|
||||
"模块大小估算为零".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 估算版本
|
||||
|
||||
+19
-14
@@ -92,28 +92,33 @@ fn read_format_copy2(matrix: &Matrix) -> u16 {
|
||||
///
|
||||
/// 从 2 处副本读取,各用 BCH 解码,取汉明距离更小者。
|
||||
/// 如果两处都无法纠错,返回 Err。
|
||||
pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), String> {
|
||||
pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), crate::error::QrError> {
|
||||
let raw1 = read_format_copy1(matrix);
|
||||
let raw2 = read_format_copy2(matrix);
|
||||
|
||||
let dec1 = bch::decode_format_info(raw1);
|
||||
let dec2 = bch::decode_format_info(raw2);
|
||||
|
||||
let corrupt_err =
|
||||
|| crate::error::QrError::FormatCorrupted("无效纠错指示位".into());
|
||||
let decode_fail =
|
||||
|| crate::error::QrError::FormatCorrupted("格式信息解码失败:两处副本均无法纠错".into());
|
||||
|
||||
// 偏好成功解码的结果
|
||||
match (dec1, dec2) {
|
||||
(Some((ec1, m1)), Some((ec2, m2))) if (ec1, m1) == (ec2, m2) => ec_from_bits(ec1)
|
||||
.map(|lvl| (lvl, m1))
|
||||
.ok_or_else(|| "无效纠错指示位".into()),
|
||||
(Some((ec1, m1)), Some((ec2, m2))) if (ec1, m1) == (ec2, m2) => {
|
||||
ec_from_bits(ec1).map(|lvl| (lvl, m1)).ok_or_else(corrupt_err)
|
||||
}
|
||||
(Some((ec1, m1)), Some((_, _))) => {
|
||||
// 两处不一致 — 偏好副本 1
|
||||
ec_from_bits(ec1)
|
||||
.map(|lvl| (lvl, m1))
|
||||
.ok_or_else(|| "无效纠错指示位".into())
|
||||
.ok_or_else(corrupt_err)
|
||||
}
|
||||
(Some((ec, m)), None) | (None, Some((ec, m))) => ec_from_bits(ec)
|
||||
.map(|lvl| (lvl, m))
|
||||
.ok_or_else(|| "无效纠错指示位".into()),
|
||||
(None, None) => Err("格式信息解码失败:两处副本均无法纠错".into()),
|
||||
.ok_or_else(corrupt_err),
|
||||
(None, None) => Err(decode_fail()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ pub(crate) fn read_format_info(matrix: &Matrix) -> Result<(EcLevel, u8), String>
|
||||
///
|
||||
/// 从 2 处副本读取,与格式信息策略相同。
|
||||
/// 版本 < 7 时矩阵中无版本信息,此时应从尺寸反推。
|
||||
pub(crate) fn read_version_info(matrix: &Matrix) -> Result<u8, String> {
|
||||
pub(crate) fn read_version_info(matrix: &Matrix) -> Result<u8, crate::error::QrError> {
|
||||
let s = matrix.size as usize;
|
||||
if s < 45 {
|
||||
// 版本 1~6 无版本信息,从尺寸推算
|
||||
@@ -129,7 +134,9 @@ pub(crate) fn read_version_info(matrix: &Matrix) -> Result<u8, String> {
|
||||
if (1..=6).contains(&ver) {
|
||||
return Ok(ver);
|
||||
}
|
||||
return Err("无法从尺寸推算版本".into());
|
||||
return Err(crate::error::QrError::FormatCorrupted(
|
||||
"无法从尺寸推算版本".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let raw1 = read_version_copy1(matrix);
|
||||
@@ -145,7 +152,9 @@ pub(crate) fn read_version_info(matrix: &Matrix) -> Result<u8, String> {
|
||||
Ok(v1)
|
||||
}
|
||||
(Some(v), None) | (None, Some(v)) => Ok(v),
|
||||
(None, None) => Err("版本信息解码失败:两处副本均无法纠错".into()),
|
||||
(None, None) => Err(crate::error::QrError::FormatCorrupted(
|
||||
"版本信息解码失败:两处副本均无法纠错".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +195,6 @@ fn read_version_copy2(matrix: &Matrix) -> u32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::matrix::grid::Matrix;
|
||||
use crate::matrix::patterns::{
|
||||
encode_format_info, place_finder_patterns, place_format_info, place_timing_patterns,
|
||||
};
|
||||
use crate::qr::{QrCode, QrConfig};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
/// 从图像字节加载并二值化
|
||||
///
|
||||
/// 步骤:解码 → 灰度 → 按中位数阈值二值化
|
||||
pub(crate) fn load_and_binarize(bytes: &[u8]) -> Result<Vec<Vec<bool>>, String> {
|
||||
let img = image::load_from_memory(bytes).map_err(|e| format!("图像解码失败: {e}"))?;
|
||||
pub(crate) fn load_and_binarize(
|
||||
bytes: &[u8],
|
||||
) -> Result<Vec<Vec<bool>>, crate::error::QrError> {
|
||||
let img =
|
||||
image::load_from_memory(bytes).map_err(|e| crate::error::QrError::DecodeFail(format!("图像解码失败: {e}")))?;
|
||||
let gray = img.to_luma8();
|
||||
|
||||
let (w, h) = gray.dimensions();
|
||||
|
||||
+12
-8
@@ -22,6 +22,7 @@ mod mode_decode;
|
||||
mod perspective;
|
||||
mod rs_decode;
|
||||
|
||||
use crate::error::QrError;
|
||||
use crate::matrix::mask::apply_mask;
|
||||
use crate::version::{EcLevel, Version};
|
||||
|
||||
@@ -47,7 +48,7 @@ pub struct DecodeResult {
|
||||
///
|
||||
/// # 返回
|
||||
/// `DecodeResult` 包含解码文本和元信息
|
||||
pub fn decode_image(bytes: &[u8]) -> Result<DecodeResult, String> {
|
||||
pub fn decode_image(bytes: &[u8]) -> Result<DecodeResult, QrError> {
|
||||
let gray = image::load_and_binarize(bytes)?;
|
||||
|
||||
// 第一遍:直接检测
|
||||
@@ -70,22 +71,25 @@ pub fn decode_image(bytes: &[u8]) -> Result<DecodeResult, String> {
|
||||
///
|
||||
/// # 返回
|
||||
/// `DecodeResult` 包含解码文本和元信息
|
||||
pub fn decode_matrix(matrix: &[Vec<bool>]) -> Result<DecodeResult, String> {
|
||||
pub fn decode_matrix(matrix: &[Vec<bool>]) -> Result<DecodeResult, QrError> {
|
||||
// 1. 构建 Matrix 对象
|
||||
let size = matrix.len() as u8;
|
||||
if matrix.is_empty() || matrix[0].is_empty() {
|
||||
return Err("空矩阵".into());
|
||||
return Err(QrError::DecodeFail("空矩阵".into()));
|
||||
}
|
||||
|
||||
// 验证方形
|
||||
if matrix.iter().any(|r| r.len() != size as usize) {
|
||||
return Err("矩阵不是方形".into());
|
||||
return Err(QrError::DecodeFail("矩阵不是方形".into()));
|
||||
}
|
||||
|
||||
// 从尺寸推算版本
|
||||
let version = ((size as i32 - 17) / 4) as u8;
|
||||
if !(1..=40).contains(&version) || (17 + version as i32 * 4) != size as i32 {
|
||||
return Err(format!("无法从尺寸 {} 推算版本", size));
|
||||
return Err(QrError::DecodeFail(format!(
|
||||
"无法从尺寸 {} 推算版本",
|
||||
size
|
||||
)));
|
||||
}
|
||||
|
||||
// 构建 Matrix 对象(简化:不预标注保留区域,BCH 读取函数直接访问坐标)
|
||||
@@ -106,7 +110,7 @@ pub fn decode_matrix(matrix: &[Vec<bool>]) -> Result<DecodeResult, String> {
|
||||
place_finder_patterns(&mut m);
|
||||
place_timing_patterns(&mut m);
|
||||
// 对齐图案位置依赖于版本,需要从版本查询
|
||||
let ver = Version::new(version).ok_or("无效版本号")?;
|
||||
let ver = Version::new(version).ok_or(QrError::InvalidVersion(version))?;
|
||||
place_alignment_patterns(&mut m, ver.alignment_positions());
|
||||
reserve_format_areas(&mut m);
|
||||
if version >= 7 {
|
||||
@@ -120,9 +124,9 @@ pub fn decode_matrix(matrix: &[Vec<bool>]) -> Result<DecodeResult, String> {
|
||||
if version >= 7 {
|
||||
let ver_info = format::read_version_info(&m)?;
|
||||
if ver_info != version {
|
||||
return Err(format!(
|
||||
return Err(QrError::DecodeFail(format!(
|
||||
"版本信息不匹配:尺寸估算 v{version},版本信息 v{ver_info}"
|
||||
));
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-175
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 逆向 process: 读模式指示符(4-bit) → 读字符计数 → 按模式解码数据位 → 拼接文本
|
||||
|
||||
use crate::encoder::mode::ALPHANUMERIC_CHARS;
|
||||
use crate::encoder::mode::{Mode, ALPHANUMERIC_CHARS};
|
||||
|
||||
/// 从位向量读取 N 位,转为 u16(MSB 优先),自动推进位置
|
||||
fn read_bits(bits: &[bool], pos: &mut usize, n: usize) -> u16 {
|
||||
@@ -17,37 +17,9 @@ fn read_bits(bits: &[bool], pos: &mut usize, n: usize) -> u16 {
|
||||
val
|
||||
}
|
||||
|
||||
/// 模式的字符计数位数(与 Mode::count_bits 一致)
|
||||
fn char_count_bits(mode: u8, version: u8) -> u8 {
|
||||
let ver = if version <= 9 {
|
||||
9
|
||||
} else if version <= 26 {
|
||||
26
|
||||
} else {
|
||||
40
|
||||
};
|
||||
match mode {
|
||||
0b0001 => match ver {
|
||||
9 => 10,
|
||||
26 => 12,
|
||||
_ => 14,
|
||||
}, // Numeric
|
||||
0b0010 => match ver {
|
||||
9 => 9,
|
||||
26 => 11,
|
||||
_ => 13,
|
||||
}, // Alphanumeric
|
||||
0b0100 => match ver {
|
||||
9 => 8,
|
||||
_ => 16,
|
||||
}, // Byte
|
||||
0b1000 => match ver {
|
||||
9 => 8,
|
||||
26 => 10,
|
||||
_ => 12,
|
||||
}, // Kanji
|
||||
_ => 0,
|
||||
}
|
||||
/// 从 mode 指示符获取字符计数位宽(复用 encoder::Mode::count_bits)
|
||||
fn char_count_bits(mode_indicator: u8, version: u8) -> Option<u8> {
|
||||
Mode::from_indicator(mode_indicator).map(|m| m.count_bits(version))
|
||||
}
|
||||
|
||||
/// 数字模式解码
|
||||
@@ -124,123 +96,45 @@ fn decode_kanji(bits: &[bool], pos: &mut usize, count: u16) -> String {
|
||||
|
||||
/// 将 13-bit 的 Shift JIS 编码值转换回 Unicode 字符
|
||||
///
|
||||
/// 逆向实现 `mode.rs::encode_kanji` 的逻辑:
|
||||
/// 13-bit 值 → (hi_byte, lo_byte) → Unicode 码点
|
||||
/// 使用 `encoding_rs` 实现精确的 Shift JIS → Unicode 逆向映射。
|
||||
fn shift_jis_value_to_char(val: u16) -> Option<char> {
|
||||
// 反推 Shift JIS 字节对
|
||||
// 高字节在 0x81..0x9F 时,值范围 0..0x1C6C (约 0xBC * 31)
|
||||
// 高字节在 0xE0..0xEF 时,需要额外偏移
|
||||
// 从 13-bit 值反推 Shift JIS 字节对
|
||||
let hi: u8;
|
||||
let lo: u8;
|
||||
|
||||
// Shift JIS → Unicode 查找表(覆盖常用 CJK 区域)
|
||||
// 从 13-bit 值反推:
|
||||
// 13-bit = (hi - 0x81) * 0xBC + (lo_offset)
|
||||
// 如果 hi >= 0xE0: 13-bit += (0xC0 - 0x9F) * 0xBC
|
||||
// lo_offset = 0 if lo in [0x40..0x7E], = (lo - 0x40) if in [0x80..0xFC]
|
||||
|
||||
// 简化反推(与编码器的线性近似一致):
|
||||
let val32 = val as u32;
|
||||
|
||||
if val32 < 0x1C6C {
|
||||
if val < 0x1C6C {
|
||||
// 高字节在 0x81..0x9F 范围
|
||||
let hi_off = val32 / 0xBC;
|
||||
let lo_idx = val32 % 0xBC;
|
||||
let hi = 0x81 + hi_off as u8;
|
||||
let lo = if lo_idx < 0x3F {
|
||||
0x40 + lo_idx as u8
|
||||
} else {
|
||||
0x41 + lo_idx as u8
|
||||
};
|
||||
shift_jis_to_unicode(hi, lo)
|
||||
let hi_off = (val / 0xBC) as u8;
|
||||
let lo_idx = (val % 0xBC) as u8;
|
||||
hi = 0x81 + hi_off;
|
||||
lo = if lo_idx < 0x3F { 0x40 + lo_idx } else { 0x41 + lo_idx };
|
||||
} else {
|
||||
// 高字节在 0xE0..0xEF 范围
|
||||
let offset = val32 - 0x1C6C;
|
||||
let hi_off = 31 + offset / 0xBC;
|
||||
let lo_idx = offset % 0xBC;
|
||||
let hi = 0xE0 + (hi_off - 31) as u8;
|
||||
let lo = if lo_idx < 0x3F {
|
||||
0x40 + lo_idx as u8
|
||||
} else {
|
||||
0x41 + lo_idx as u8
|
||||
};
|
||||
shift_jis_to_unicode(hi, lo)
|
||||
}
|
||||
let offset = val - 0x1C6C;
|
||||
let hi_off = (offset / 0xBC) as u8;
|
||||
let lo_idx = (offset % 0xBC) as u8;
|
||||
hi = 0xE0 + hi_off;
|
||||
lo = if lo_idx < 0x3F { 0x40 + lo_idx } else { 0x41 + lo_idx };
|
||||
}
|
||||
|
||||
/// Shift JIS 字节对 → Unicode 码点
|
||||
fn shift_jis_to_unicode(hi: u8, lo: u8) -> Option<char> {
|
||||
// 标准 Shift JIS → Unicode 映射表(覆盖 BMP CJK)
|
||||
// 简化版:处理常见区域 0x81-0x9F / 0xE0-0xEF
|
||||
|
||||
if !is_valid_shift_jis(hi, lo) {
|
||||
// 使用 encoding_rs 精确解码 Shift JIS → UTF-8
|
||||
let sjis_bytes = [hi, lo];
|
||||
let mut output = String::with_capacity(4);
|
||||
let (result, _enc, _read) =
|
||||
encoding_rs::SHIFT_JIS.new_decoder_without_bom_handling()
|
||||
.decode_to_str_without_replacement(&sjis_bytes, &mut output, true);
|
||||
use encoding_rs::DecoderResult;
|
||||
if !matches!(result, DecoderResult::InputEmpty) || output.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 使用简化的偏移映射
|
||||
// 对于 0x81 区(JIS X 0208 行 1-62)
|
||||
let hi_offset = if hi <= 0x9F {
|
||||
(hi - 0x81) as u32
|
||||
} else {
|
||||
(hi - 0xE0 + 31) as u32
|
||||
};
|
||||
|
||||
let lo_offset = if lo <= 0x7E {
|
||||
(lo - 0x40) as u32
|
||||
} else {
|
||||
(lo - 0x41) as u32
|
||||
};
|
||||
|
||||
if lo_offset >= 0xBC {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 简化 Unicode 码点计算(近似值,对应编码器的简化逻辑)
|
||||
// 实际 QR 码标准使用 JIS X 0208 字符集
|
||||
let jis_row = hi_offset; // 0..93
|
||||
let jis_cell = lo_offset; // 0..187
|
||||
|
||||
// 简化的 JIS → Unicode 映射(覆盖常用字符)
|
||||
jis_to_unicode(jis_row as u16, jis_cell as u16)
|
||||
}
|
||||
|
||||
fn is_valid_shift_jis(hi: u8, lo: u8) -> bool {
|
||||
if !(0x81..=0xEF).contains(&hi) || hi == 0xA0 {
|
||||
return false;
|
||||
}
|
||||
matches!(lo, 0x40..=0x7E | 0x80..=0xFC)
|
||||
}
|
||||
|
||||
/// JIS X 0208 行列 → Unicode(简化映射,覆盖 QR 汉字常用范围)
|
||||
fn jis_to_unicode(row: u16, cell: u16) -> Option<char> {
|
||||
// 对偶数字节映射: 常见的 JIS 汉字区域映射到 Unicode CJK
|
||||
// 这是简化映射,与编码器中的 unicode_to_shift_jis 的线性近似对应
|
||||
|
||||
if (0x21..=0x7E).contains(&row) {
|
||||
// 非汉字区域(符号、数字、字母、假名)
|
||||
// 简化的 Unicode 偏移
|
||||
if row <= 0x28 {
|
||||
// 符号区 → Unicode 0x3000+
|
||||
let cp = 0x3000u32 + ((row - 0x21) as u32 * 0xBC + cell as u32);
|
||||
char::from_u32(cp)
|
||||
} else if row <= 0x2F {
|
||||
// 数字/字母区 → Unicode 0xFF00+
|
||||
let cp = 0xFF00u32 + ((row - 0x29) as u32 * 0xBC + cell as u32);
|
||||
char::from_u32(cp)
|
||||
} else if row <= 0x51 {
|
||||
// JIS 一级汉字 → Unicode CJK 0x4E00+
|
||||
let cp = 0x4E00u32 + ((row - 0x30) as u32 * 0xBC + cell as u32);
|
||||
char::from_u32(cp)
|
||||
} else {
|
||||
// JIS 二级汉字 → Unicode CJK 0x8000+
|
||||
let cp = 0x8000u32 + ((row - 0x52) as u32 * 0xBC + cell as u32);
|
||||
char::from_u32(cp)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
output.chars().next()
|
||||
}
|
||||
|
||||
/// 解码主函数:比特流 → 文本
|
||||
pub(crate) fn decode_bitstream(bits: &[bool], version: u8) -> Result<String, String> {
|
||||
pub(crate) fn decode_bitstream(
|
||||
bits: &[bool],
|
||||
version: u8,
|
||||
) -> Result<String, crate::error::QrError> {
|
||||
let mut pos = 0;
|
||||
let mut text = String::new();
|
||||
|
||||
@@ -249,51 +143,38 @@ pub(crate) fn decode_bitstream(bits: &[bool], version: u8) -> Result<String, Str
|
||||
break;
|
||||
}
|
||||
let mode_indicator = read_bits(bits, &mut pos, 4) as u8;
|
||||
if mode_indicator == 0b0000 {
|
||||
break; // 终止符
|
||||
}
|
||||
|
||||
let count_bits =
|
||||
char_count_bits(mode_indicator, version).ok_or_else(|| {
|
||||
crate::error::QrError::DecodeFail(format!(
|
||||
"未知模式指示符: {:04b}",
|
||||
mode_indicator
|
||||
))
|
||||
})? as usize;
|
||||
if pos + count_bits > bits.len() {
|
||||
break;
|
||||
}
|
||||
let count = read_bits(bits, &mut pos, count_bits);
|
||||
|
||||
match mode_indicator {
|
||||
0b0001 => {
|
||||
// Numeric
|
||||
let count_bits = char_count_bits(0b0001, version) as usize;
|
||||
if pos + count_bits > bits.len() {
|
||||
break;
|
||||
0b0001 => text.push_str(&decode_numeric(bits, &mut pos, count)),
|
||||
0b0010 => text.push_str(&decode_alphanumeric(bits, &mut pos, count)),
|
||||
0b0100 => text.push_str(&decode_byte(bits, &mut pos, count)),
|
||||
0b1000 => text.push_str(&decode_kanji(bits, &mut pos, count)),
|
||||
_ => {
|
||||
return Err(crate::error::QrError::DecodeFail(format!(
|
||||
"未知模式指示符: {:04b}",
|
||||
mode_indicator
|
||||
)))
|
||||
}
|
||||
let count = read_bits(bits, &mut pos, count_bits);
|
||||
text.push_str(&decode_numeric(bits, &mut pos, count));
|
||||
}
|
||||
0b0010 => {
|
||||
// Alphanumeric
|
||||
let count_bits = char_count_bits(0b0010, version) as usize;
|
||||
if pos + count_bits > bits.len() {
|
||||
break;
|
||||
}
|
||||
let count = read_bits(bits, &mut pos, count_bits);
|
||||
text.push_str(&decode_alphanumeric(bits, &mut pos, count));
|
||||
}
|
||||
0b0100 => {
|
||||
// Byte
|
||||
let count_bits = char_count_bits(0b0100, version) as usize;
|
||||
if pos + count_bits > bits.len() {
|
||||
break;
|
||||
}
|
||||
let count = read_bits(bits, &mut pos, count_bits);
|
||||
text.push_str(&decode_byte(bits, &mut pos, count));
|
||||
}
|
||||
0b1000 => {
|
||||
// Kanji
|
||||
let count_bits = char_count_bits(0b1000, version) as usize;
|
||||
if pos + count_bits > bits.len() {
|
||||
break;
|
||||
}
|
||||
let count = read_bits(bits, &mut pos, count_bits);
|
||||
text.push_str(&decode_kanji(bits, &mut pos, count));
|
||||
}
|
||||
0b0000 => break, // 终止符
|
||||
_ => return Err(format!("未知模式指示符: {:04b}", mode_indicator)),
|
||||
}
|
||||
}
|
||||
|
||||
if text.is_empty() {
|
||||
Err("未解码到任何文本".into())
|
||||
Err(crate::error::QrError::DecodeFail("未解码到任何文本".into()))
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ use crate::ecc::galois;
|
||||
///
|
||||
/// # 错误
|
||||
/// 如果错误数超过 `ec_count / 2`,返回 Err
|
||||
pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec<u8>, usize), String> {
|
||||
pub(crate) fn rs_correct(
|
||||
data: &[u8],
|
||||
ec: &[u8],
|
||||
) -> Result<(Vec<u8>, usize), crate::error::QrError> {
|
||||
let ec_count = ec.len();
|
||||
let n = data.len() + ec_count;
|
||||
|
||||
@@ -37,7 +40,9 @@ pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec<u8>, usize), Str
|
||||
let error_positions = chien_search(&lambda, n)?;
|
||||
|
||||
if error_positions.is_empty() {
|
||||
return Err("检测到错误但无法定位".into());
|
||||
return Err(crate::error::QrError::DecodeFail(
|
||||
"检测到错误但无法定位".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Forney 算法求错误幅值
|
||||
@@ -54,7 +59,9 @@ pub(crate) fn rs_correct(data: &[u8], ec: &[u8]) -> Result<(Vec<u8>, usize), Str
|
||||
// 6. 验证
|
||||
let verify_syn = compute_syndromes(&corrected, ec_count);
|
||||
if verify_syn.iter().any(|&s| s != 0) {
|
||||
return Err("纠错失败:验证未通过".into());
|
||||
return Err(crate::error::QrError::DecodeFail(
|
||||
"纠错失败:验证未通过".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((corrected[..data.len()].to_vec(), errors_corrected))
|
||||
@@ -81,7 +88,10 @@ fn compute_syndromes(received: &[u8], ec_count: usize) -> Vec<u8> {
|
||||
/// Berlekamp-Massey 算法 — 寻找错误位置多项式 Λ(x)
|
||||
///
|
||||
/// 返回 Λ 的系数向量(低次到高次),Λ[0] = 1
|
||||
fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result<Vec<u8>, String> {
|
||||
fn berlekamp_massey(
|
||||
syndromes: &[u8],
|
||||
ec_count: usize,
|
||||
) -> Result<Vec<u8>, crate::error::QrError> {
|
||||
let t = ec_count;
|
||||
let mut lambda = vec![1u8]; // Λ(x) = 1
|
||||
let mut b = vec![1u8]; // B(x) = 1
|
||||
@@ -123,7 +133,8 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result<Vec<u8>, String
|
||||
if 2 * l <= r {
|
||||
// B(x) = Λ(x) / δ
|
||||
b = lambda.clone();
|
||||
let delta_inv = galois::div(1, delta).ok_or("除法错误")?;
|
||||
let delta_inv = galois::div(1, delta)
|
||||
.ok_or_else(|| crate::error::QrError::Internal("除法错误".into()))?;
|
||||
for coeff in &mut b {
|
||||
*coeff = galois::mul(*coeff, delta_inv);
|
||||
}
|
||||
@@ -137,12 +148,16 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result<Vec<u8>, String
|
||||
}
|
||||
|
||||
if l > t {
|
||||
return Err("错误数超出纠错能力".into());
|
||||
return Err(crate::error::QrError::DecodeFail(
|
||||
"错误数超出纠错能力".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if l == 0 {
|
||||
return Err("无错误(BM 算法异常)".into());
|
||||
return Err(crate::error::QrError::DecodeFail(
|
||||
"无错误(BM 算法异常)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Strip trailing zeros
|
||||
@@ -158,7 +173,7 @@ fn berlekamp_massey(syndromes: &[u8], ec_count: usize) -> Result<Vec<u8>, String
|
||||
/// 遍历 GF(2⁸) 所有非零元素 α^i,检查 Λ(α^i) == 0
|
||||
/// 若 i 为根,则错误多项式指数 k = -i mod 255 = (255-i)%255
|
||||
/// 码字数组中对应位置 = n-1-k
|
||||
fn chien_search(lambda: &[u8], n: usize) -> Result<Vec<usize>, String> {
|
||||
fn chien_search(lambda: &[u8], n: usize) -> Result<Vec<usize>, crate::error::QrError> {
|
||||
let mut positions = Vec::new();
|
||||
|
||||
// 搜索所有可能的根 α^i for i=0..254
|
||||
@@ -177,7 +192,9 @@ fn chien_search(lambda: &[u8], n: usize) -> Result<Vec<usize>, String> {
|
||||
}
|
||||
|
||||
if positions.is_empty() {
|
||||
Err("Chien 搜索无结果".into())
|
||||
Err(crate::error::QrError::DecodeFail(
|
||||
"Chien 搜索无结果".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(positions)
|
||||
}
|
||||
|
||||
+46
-41
@@ -18,6 +18,17 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 4-bit 模式指示符还原 Mode
|
||||
pub fn from_indicator(ind: u8) -> Option<Self> {
|
||||
match ind {
|
||||
0b0001 => Some(Mode::Numeric),
|
||||
0b0010 => Some(Mode::Alphanumeric),
|
||||
0b0100 => Some(Mode::Byte),
|
||||
0b1000 => Some(Mode::Kanji),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 字符计数指示符长度(bit),取决于版本号
|
||||
pub fn count_bits(self, version: u8) -> u8 {
|
||||
match self {
|
||||
@@ -139,9 +150,8 @@ pub fn encode_byte(input: &str) -> Vec<bool> {
|
||||
/// 汉字模式编码 (Shift JIS → 13 bit)
|
||||
/// 对于无法转换为 Shift JIS 的字符,使用全零占位符(避免段内模式混用)
|
||||
///
|
||||
/// 注: segment_text 保证 Kanji 段内字符均通过 is_kanji() 检测,
|
||||
/// 但 unicode_to_shift_jis 目前仅覆盖 U+4E00-U+9FFF (基本 CJK 统一汉字)。
|
||||
/// U+3400-U+4DBF (CJK Ext-A) 和 U+3000-U+303F (CJK 符号) 的映射有待补全。
|
||||
/// 使用 `encoding_rs` 实现完整的 JIS X 0208 ↔ Unicode 双向映射,
|
||||
/// 覆盖所有标准 Shift JIS 字符(CJK、假名、符号等)。
|
||||
pub fn encode_kanji(input: &str) -> Vec<bool> {
|
||||
let mut bits = Vec::new();
|
||||
for c in input.chars() {
|
||||
@@ -158,54 +168,49 @@ pub fn encode_kanji(input: &str) -> Vec<bool> {
|
||||
bits
|
||||
}
|
||||
|
||||
/// Unicode → Shift JIS 简化转换
|
||||
/// 覆盖常用 CJK 统一汉字 (U+4E00 ~ U+9FFF)
|
||||
/// Unicode → Shift JIS → 13-bit QR 码字(使用 encoding_rs 精确映射)
|
||||
///
|
||||
/// 注意: 此映射采用线性近似公式。实际上 Unicode CJK 与 Shift JIS (JIS X 0208)
|
||||
/// 并非严格线性对应,对于非线性的字符会产生偏差。
|
||||
/// 对于需要精确映射的场景,建议使用完整的 CJK→Shift JIS 映射表。
|
||||
/// encoding_rs 提供完整的 JIS X 0208 ↔ Unicode 双向映射,符合 ISO/IEC 18004 标准。
|
||||
fn unicode_to_shift_jis(c: char) -> Option<u16> {
|
||||
let code = c as u32;
|
||||
// CJK 统一汉字 基本区
|
||||
if (0x4E00..=0x9FFF).contains(&code) {
|
||||
let base = code - 0x4E00;
|
||||
// 将单个字符编码为 Shift JIS 字节
|
||||
let mut utf8_buf = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut utf8_buf);
|
||||
let mut sjis_buf = [0u8; 4];
|
||||
let (result, _enc, _read) =
|
||||
encoding_rs::SHIFT_JIS.new_encoder().encode_from_utf8_without_replacement(
|
||||
s,
|
||||
&mut sjis_buf,
|
||||
true,
|
||||
);
|
||||
// 如果编码器报告了错误,说明有无法映射的字符
|
||||
use encoding_rs::EncoderResult;
|
||||
if !matches!(result, EncoderResult::InputEmpty) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 偏移分片: 高字节每 0xBC 个字符换一行
|
||||
let hi_offset = base / 0xBC;
|
||||
let lo_offset = base % 0xBC;
|
||||
let sjis_bytes = &sjis_buf[..2];
|
||||
let hi = sjis_bytes[0] as u16;
|
||||
let lo = sjis_bytes[1] as u16;
|
||||
|
||||
// Shift JIS 汉字有两段区间: 0x81-0x9F 和 0xE0-0xEF
|
||||
// 中间 0xA0-0xDF 为间隙,需要跳过
|
||||
let hi = if hi_offset < 31 {
|
||||
0x81u16 + hi_offset as u16
|
||||
} else {
|
||||
0xE0u16 + (hi_offset - 31) as u16
|
||||
};
|
||||
// 验证有效 Shift JIS 范围
|
||||
if !(0x81..=0xEF).contains(&hi) || hi == 0xA0 {
|
||||
return None;
|
||||
}
|
||||
if !(0x40..=0x7E).contains(&lo) && !(0x80..=0xFC).contains(&lo) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 第二字节有效范围: 0x40-0x7E 和 0x80-0xFC (跳过 0x7F)
|
||||
let lo_base = 0x40u16 + lo_offset as u16;
|
||||
let lo = if lo_base >= 0x7F {
|
||||
lo_base + 1 // 跳过无效的 0x7F
|
||||
} else {
|
||||
lo_base
|
||||
};
|
||||
|
||||
// 行内索引: 0x40..=0x7E → 0..62, 0x80..=0xFC → 63..187
|
||||
let lo_idx = if lo >= 0x80 {
|
||||
lo - 0x41 // 跳过 0x7F 后的偏移
|
||||
} else {
|
||||
lo - 0x40
|
||||
};
|
||||
// 行内索引(与 QR 码标准一致)
|
||||
let lo_idx = if lo >= 0x80 { lo - 0x41 } else { lo - 0x40 };
|
||||
|
||||
// 映射到 13-bit 码字
|
||||
let val = if (0x81..=0x9Fu16).contains(&hi) {
|
||||
let val = if (0x81..=0x9F).contains(&hi) {
|
||||
(hi - 0x81) * 0xBC + lo_idx
|
||||
} else {
|
||||
(hi - 0xC1) * 0xBC + lo_idx
|
||||
0x1C6C + (hi - 0xE0) * 0xBC + lo_idx
|
||||
};
|
||||
return Some(val);
|
||||
}
|
||||
None
|
||||
|
||||
Some(val)
|
||||
}
|
||||
|
||||
/// 判断字符是否属于数字模式
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! QR 码错误类型
|
||||
//!
|
||||
//! 为 `qr-core` 库提供类型化的错误枚举,替代裸 `String` 错误。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// QR 码编解码错误
|
||||
#[derive(Debug)]
|
||||
pub enum QrError {
|
||||
/// 输入为空
|
||||
EmptyInput,
|
||||
/// 无效版本号 (1-40)
|
||||
InvalidVersion(u8),
|
||||
/// 数据过长,超出 QR 码最大容量
|
||||
DataTooLong,
|
||||
/// 解码失败
|
||||
DecodeFail(String),
|
||||
/// 格式/版本信息损坏
|
||||
FormatCorrupted(String),
|
||||
/// 颜色格式错误
|
||||
InvalidColor(String),
|
||||
/// 图像 I/O 错误
|
||||
Image(image::ImageError),
|
||||
/// 内部错误
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for QrError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
QrError::EmptyInput => write!(f, "输入为空"),
|
||||
QrError::InvalidVersion(v) => write!(f, "无效版本号 (1-40): {v}"),
|
||||
QrError::DataTooLong => write!(f, "数据过长,超出 QR 码最大容量"),
|
||||
QrError::DecodeFail(msg) => write!(f, "解码失败: {msg}"),
|
||||
QrError::FormatCorrupted(msg) => write!(f, "格式信息损坏: {msg}"),
|
||||
QrError::InvalidColor(msg) => write!(f, "颜色格式错误: {msg}"),
|
||||
QrError::Image(e) => write!(f, "图像错误: {e}"),
|
||||
QrError::Internal(msg) => write!(f, "内部错误: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for QrError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
QrError::Image(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<image::ImageError> for QrError {
|
||||
fn from(e: image::ImageError) -> Self {
|
||||
QrError::Image(e)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
pub mod decoder;
|
||||
pub mod ecc;
|
||||
pub mod encoder;
|
||||
pub mod error;
|
||||
pub mod matrix;
|
||||
pub mod qr;
|
||||
pub mod render;
|
||||
pub mod text_builder;
|
||||
pub mod version;
|
||||
|
||||
pub use error::QrError;
|
||||
|
||||
+164
-72
@@ -15,6 +15,31 @@ pub const MASK_FNS: [MaskFn; 8] = [
|
||||
|x, y| ((x as u32 + y as u32) % 2 + (x as u32 * y as u32) % 3).is_multiple_of(2),
|
||||
];
|
||||
|
||||
/// 掩码视图 — 懒计算掩码结果,避免克隆整个 Matrix
|
||||
struct MaskedView<'a> {
|
||||
matrix: &'a Matrix,
|
||||
mask_fn: MaskFn,
|
||||
}
|
||||
|
||||
impl<'a> MaskedView<'a> {
|
||||
fn new(matrix: &'a Matrix, mask_idx: u8) -> Self {
|
||||
Self { matrix, mask_fn: MASK_FNS[mask_idx as usize] }
|
||||
}
|
||||
|
||||
fn get(&self, x: u8, y: u8) -> bool {
|
||||
let val = self.matrix.get(x, y);
|
||||
if self.matrix.is_reserved(x, y) {
|
||||
val
|
||||
} else {
|
||||
val ^ (self.mask_fn)(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> u8 {
|
||||
self.matrix.size
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用掩码到矩阵的数据区域(跳过功能图案保留区域)
|
||||
pub fn apply_mask(matrix: &Matrix, mask_idx: u8) -> Matrix {
|
||||
let mask_fn = MASK_FNS[mask_idx as usize];
|
||||
@@ -30,22 +55,29 @@ pub fn apply_mask(matrix: &Matrix, mask_idx: u8) -> Matrix {
|
||||
result
|
||||
}
|
||||
|
||||
/// 惩罚评分(越低越好)
|
||||
/// 惩罚评分(越低越好)— 对 MaskedView 评分,避免 Matrix 克隆
|
||||
fn score_view(view: &MaskedView) -> u32 {
|
||||
score_rule1(view) + score_rule2(view) + score_rule3(view) + score_rule4(view)
|
||||
}
|
||||
|
||||
/// 惩罚评分(原始 Matrix,用于测试兼容)
|
||||
pub fn score(matrix: &Matrix) -> u32 {
|
||||
score_rule1(matrix) + score_rule2(matrix) + score_rule3(matrix) + score_rule4(matrix)
|
||||
// 使用 mask 0 的视图(无掩码时等价于原始矩阵的非保留区域值)
|
||||
// 通过 identity view 实现
|
||||
score_rule1_raw(matrix) + score_rule2_raw(matrix) + score_rule3_raw(matrix) + score_rule4_raw(matrix)
|
||||
}
|
||||
|
||||
/// 规则 1: 连续 5+ 同色行/列 → N1 + k - 5
|
||||
fn score_rule1(matrix: &Matrix) -> u32 {
|
||||
fn score_rule1(view: &MaskedView) -> u32 {
|
||||
let mut penalty = 0u32;
|
||||
let n = matrix.size as usize;
|
||||
let n = view.size() as usize;
|
||||
|
||||
// 水平扫描
|
||||
for y in 0..n {
|
||||
let mut run = 1u32;
|
||||
let mut prev = matrix.get(0, y as u8);
|
||||
let mut prev = view.get(0, y as u8);
|
||||
for x in 1..n {
|
||||
let cur = matrix.get(x as u8, y as u8);
|
||||
let cur = view.get(x as u8, y as u8);
|
||||
if cur == prev {
|
||||
run += 1;
|
||||
} else {
|
||||
@@ -64,9 +96,9 @@ fn score_rule1(matrix: &Matrix) -> u32 {
|
||||
// 垂直扫描
|
||||
for x in 0..n {
|
||||
let mut run = 1u32;
|
||||
let mut prev = matrix.get(x as u8, 0);
|
||||
let mut prev = view.get(x as u8, 0);
|
||||
for y in 1..n {
|
||||
let cur = matrix.get(x as u8, y as u8);
|
||||
let cur = view.get(x as u8, y as u8);
|
||||
if cur == prev {
|
||||
run += 1;
|
||||
} else {
|
||||
@@ -85,8 +117,57 @@ fn score_rule1(matrix: &Matrix) -> u32 {
|
||||
penalty
|
||||
}
|
||||
|
||||
/// 原始 Matrix 版规则 1(用于 score() 公共 API)
|
||||
fn score_rule1_raw(matrix: &Matrix) -> u32 {
|
||||
let mut penalty = 0u32;
|
||||
let n = matrix.size as usize;
|
||||
|
||||
for y in 0..n {
|
||||
let mut run = 1u32;
|
||||
let mut prev = matrix.get(0, y as u8);
|
||||
for x in 1..n {
|
||||
let cur = matrix.get(x as u8, y as u8);
|
||||
if cur == prev { run += 1; } else {
|
||||
if run >= 5 { penalty += 3 + run - 5; }
|
||||
run = 1; prev = cur;
|
||||
}
|
||||
}
|
||||
if run >= 5 { penalty += 3 + run - 5; }
|
||||
}
|
||||
for x in 0..n {
|
||||
let mut run = 1u32;
|
||||
let mut prev = matrix.get(x as u8, 0);
|
||||
for y in 1..n {
|
||||
let cur = matrix.get(x as u8, y as u8);
|
||||
if cur == prev { run += 1; } else {
|
||||
if run >= 5 { penalty += 3 + run - 5; }
|
||||
run = 1; prev = cur;
|
||||
}
|
||||
}
|
||||
if run >= 5 { penalty += 3 + run - 5; }
|
||||
}
|
||||
penalty
|
||||
}
|
||||
|
||||
/// 规则 2: 同色 2×2 方块,每个 +3
|
||||
fn score_rule2(matrix: &Matrix) -> u32 {
|
||||
fn score_rule2(view: &MaskedView) -> u32 {
|
||||
let mut count = 0u32;
|
||||
let n = view.size();
|
||||
for y in 0..n - 1 {
|
||||
for x in 0..n - 1 {
|
||||
let v = view.get(x, y);
|
||||
if view.get(x + 1, y) == v
|
||||
&& view.get(x, y + 1) == v
|
||||
&& view.get(x + 1, y + 1) == v
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count * 3
|
||||
}
|
||||
|
||||
fn score_rule2_raw(matrix: &Matrix) -> u32 {
|
||||
let mut count = 0u32;
|
||||
let n = matrix.size;
|
||||
for y in 0..n - 1 {
|
||||
@@ -104,63 +185,61 @@ fn score_rule2(matrix: &Matrix) -> u32 {
|
||||
}
|
||||
|
||||
/// 规则 3: 检测 1011101 模式(及其反转),每次 +40
|
||||
fn score_rule3(matrix: &Matrix) -> u32 {
|
||||
fn score_rule3(view: &MaskedView) -> u32 {
|
||||
let mut penalty = 0u32;
|
||||
let n = view.size() as usize;
|
||||
|
||||
const PAT_FWD: [bool; 11] = [true, false, true, true, true, false, true, false, false, false, false];
|
||||
const PAT_REV: [bool; 11] = [false, false, false, false, true, false, true, true, true, false, true];
|
||||
|
||||
for y in 0..n {
|
||||
for x in 0..n.saturating_sub(10) {
|
||||
if (0..11).all(|i| view.get((x + i) as u8, y as u8) == PAT_FWD[i])
|
||||
|| (0..11).all(|i| view.get((x + i) as u8, y as u8) == PAT_REV[i])
|
||||
{
|
||||
penalty += 40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for y in 0..n.saturating_sub(10) {
|
||||
for x in 0..n {
|
||||
if (0..11).all(|i| view.get(x as u8, (y + i) as u8) == PAT_FWD[i])
|
||||
|| (0..11).all(|i| view.get(x as u8, (y + i) as u8) == PAT_REV[i])
|
||||
{
|
||||
penalty += 40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
penalty
|
||||
}
|
||||
|
||||
fn score_rule3_raw(matrix: &Matrix) -> u32 {
|
||||
let mut penalty = 0u32;
|
||||
let n = matrix.size as usize;
|
||||
|
||||
// 水平方向
|
||||
const PAT_FWD: [bool; 11] = [true, false, true, true, true, false, true, false, false, false, false];
|
||||
const PAT_REV: [bool; 11] = [false, false, false, false, true, false, true, true, true, false, true];
|
||||
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
if x + 10 >= n {
|
||||
continue;
|
||||
}
|
||||
// 正模式: 10111010000
|
||||
let forward = (0..11).all(|i| {
|
||||
let expected = [
|
||||
true, false, true, true, true, false, true, false, false, false, false,
|
||||
][i];
|
||||
matrix.get((x + i) as u8, y as u8) == expected
|
||||
});
|
||||
if forward {
|
||||
penalty += 40;
|
||||
}
|
||||
|
||||
// 反模式: 00001011101
|
||||
let reverse = (0..11).all(|i| {
|
||||
let expected = [
|
||||
false, false, false, false, true, false, true, true, true, false, true,
|
||||
][i];
|
||||
matrix.get((x + i) as u8, y as u8) == expected
|
||||
});
|
||||
if reverse {
|
||||
if x + 10 < n
|
||||
&& ((0..11).all(|i| matrix.get((x + i) as u8, y as u8) == PAT_FWD[i])
|
||||
|| (0..11).all(|i| matrix.get((x + i) as u8, y as u8) == PAT_REV[i]))
|
||||
{
|
||||
penalty += 40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 垂直方向
|
||||
for y in 0..n {
|
||||
if y + 10 >= n {
|
||||
continue;
|
||||
}
|
||||
for x in 0..n {
|
||||
let forward = (0..11).all(|i| {
|
||||
let expected = [
|
||||
true, false, true, true, true, false, true, false, false, false, false,
|
||||
][i];
|
||||
matrix.get(x as u8, (y + i) as u8) == expected
|
||||
});
|
||||
if forward {
|
||||
penalty += 40;
|
||||
}
|
||||
|
||||
let reverse = (0..11).all(|i| {
|
||||
let expected = [
|
||||
false, false, false, false, true, false, true, true, true, false, true,
|
||||
][i];
|
||||
matrix.get(x as u8, (y + i) as u8) == expected
|
||||
});
|
||||
if reverse {
|
||||
if (0..11).all(|i| matrix.get(x as u8, (y + i) as u8) == PAT_FWD[i])
|
||||
|| (0..11).all(|i| matrix.get(x as u8, (y + i) as u8) == PAT_REV[i])
|
||||
{
|
||||
penalty += 40;
|
||||
}
|
||||
}
|
||||
@@ -170,37 +249,45 @@ fn score_rule3(matrix: &Matrix) -> u32 {
|
||||
}
|
||||
|
||||
/// 规则 4: 暗模块占比偏离 50%,每 5% +10
|
||||
fn score_rule4(matrix: &Matrix) -> u32 {
|
||||
fn score_rule4(view: &MaskedView) -> u32 {
|
||||
let total = (view.size() as u32) * (view.size() as u32);
|
||||
let dark: u32 = (0..view.size())
|
||||
.flat_map(|y| (0..view.size()).map(move |x| view.get(x, y) as u32))
|
||||
.sum();
|
||||
|
||||
let pct = (dark * 100 + total / 2) / total;
|
||||
let deviation = ((pct as i32 - 50).unsigned_abs()) / 5;
|
||||
deviation * 10
|
||||
}
|
||||
|
||||
fn score_rule4_raw(matrix: &Matrix) -> u32 {
|
||||
let total = (matrix.size as u32) * (matrix.size as u32);
|
||||
let dark: u32 = (0..matrix.size)
|
||||
.flat_map(|y| (0..matrix.size).map(move |x| matrix.get(x, y) as u32))
|
||||
.sum();
|
||||
|
||||
let pct = (dark * 100 + total / 2) / total; // 四舍五入
|
||||
let pct = (dark * 100 + total / 2) / total;
|
||||
let deviation = ((pct as i32 - 50).unsigned_abs()) / 5;
|
||||
deviation * 10
|
||||
}
|
||||
|
||||
/// 评估所有 8 种掩码,返回最佳掩码编号和对应矩阵
|
||||
///
|
||||
/// 优化:使用 MaskedView 懒计算,避免每种掩码克隆整个 Matrix(版本 40 可节省 ~248KB 临时分配)
|
||||
pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) {
|
||||
let mut best_idx = 0u8;
|
||||
let mut best_score = u32::MAX;
|
||||
let mut best_matrix: Option<Matrix> = None;
|
||||
|
||||
for i in 0..8u8 {
|
||||
let masked = apply_mask(matrix, i);
|
||||
let s = score(&masked);
|
||||
if best_matrix.is_none() || s < best_score {
|
||||
let view = MaskedView::new(matrix, i);
|
||||
let s = score_view(&view);
|
||||
if s < best_score {
|
||||
best_score = s;
|
||||
best_idx = i;
|
||||
best_matrix = Some(masked);
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
best_idx,
|
||||
best_matrix.expect("掩码循环 (0..8) 至少执行一次,best_matrix 必定有值"),
|
||||
)
|
||||
// 只对最佳掩码应用一次克隆
|
||||
(best_idx, apply_mask(matrix, best_idx))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -210,9 +297,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_apply_mask() {
|
||||
let m = Matrix::new(21);
|
||||
// 不设置 reserved,所有区域都是数据区
|
||||
let masked = apply_mask(&m, 0); // (x+y) % 2 == 0
|
||||
// 初始全白,掩码 0 会在 (x+y)%2==0 的位置翻转
|
||||
assert_eq!(masked.get(0, 0), true); // (0+0)%2=0 → 翻转
|
||||
assert_eq!(masked.get(1, 0), false); // (1+0)%2=1 → 不变
|
||||
}
|
||||
@@ -220,27 +305,24 @@ mod tests {
|
||||
#[test]
|
||||
fn test_score_rule2() {
|
||||
let mut m = Matrix::new(3);
|
||||
// 全黑 → 4 个 2×2 方块
|
||||
for y in 0..3u8 {
|
||||
for x in 0..3u8 {
|
||||
m.set(x, y, true);
|
||||
}
|
||||
}
|
||||
assert_eq!(score_rule2(&m), 4 * 3); // 4 blocks × 3 = 12
|
||||
let view = MaskedView::new(&m, 0);
|
||||
assert_eq!(score_rule2(&view), 4 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_rule4() {
|
||||
let m = Matrix::new(10);
|
||||
// 全部白色 → 0% dark → 偏离 50% = 10 × 5% → penalty = 10 × 10 = 100
|
||||
let s = score_rule4(&m);
|
||||
assert_eq!(s, 100);
|
||||
assert_eq!(score_rule4_raw(&m), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_best_mask_selects_something() {
|
||||
let mut m = Matrix::new(21);
|
||||
// 填一些随机数据
|
||||
for y in 0..21u8 {
|
||||
for x in 0..21u8 {
|
||||
m.set(x, y, (x as u32 * y as u32) % 3 == 0);
|
||||
@@ -249,4 +331,14 @@ mod tests {
|
||||
let (idx, _masked) = best_mask(&m);
|
||||
assert!(idx < 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_roundtrip_masked_equals_original_for_mask0_on_empty() {
|
||||
// 验证 score(Matrix) 和 score_view(MaskedView) 在无保留区时一致
|
||||
let m = Matrix::new(21);
|
||||
let view = MaskedView::new(&m, 0); // mask 0 flips (x+y)%2==0
|
||||
let s_view = score_view(&view);
|
||||
let s_apply = score(&apply_mask(&m, 0));
|
||||
assert_eq!(s_view, s_apply, "懒评分与直接评分应一致");
|
||||
}
|
||||
}
|
||||
|
||||
+59
-53
@@ -1,6 +1,7 @@
|
||||
use crate::ecc::reed_solomon;
|
||||
use crate::encoder::bitstream::build_codewords;
|
||||
use crate::encoder::segment::{segment_bit_length, segment_text};
|
||||
use crate::error::QrError;
|
||||
use crate::matrix::grid::Matrix;
|
||||
use crate::matrix::mask::best_mask;
|
||||
use crate::matrix::patterns::{
|
||||
@@ -63,21 +64,15 @@ pub struct QrConfig {
|
||||
pub version: VersionMode,
|
||||
/// 静区边距(模块数),默认 4
|
||||
pub margin: u8,
|
||||
/// 前景色(CSS 格式 "#RRGGBB"),默认 "#000000"
|
||||
pub fg_color: Option<String>,
|
||||
/// 背景色(CSS 格式 "#RRGGBB"),默认 "#FFFFFF"
|
||||
pub bg_color: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for QrConfig {
|
||||
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距 + 黑前景白背景
|
||||
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距
|
||||
fn default() -> Self {
|
||||
QrConfig {
|
||||
level: EcLevel::M,
|
||||
version: VersionMode::Auto,
|
||||
margin: 4,
|
||||
fg_color: None,
|
||||
bg_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,10 +109,6 @@ pub struct QrCode {
|
||||
matrix: Matrix,
|
||||
/// 静区边距(模块数)
|
||||
pub margin: u8,
|
||||
/// 前景色 RGB [r, g, b]
|
||||
pub fg_color: [u8; 3],
|
||||
/// 背景色 RGB [r, g, b]
|
||||
pub bg_color: [u8; 3],
|
||||
}
|
||||
|
||||
impl QrCode {
|
||||
@@ -142,16 +133,16 @@ impl QrCode {
|
||||
/// assert_eq!(qr.version.0, 1);
|
||||
/// assert_eq!(qr.size(), 21);
|
||||
/// ```
|
||||
pub fn encode(text: &str, config: QrConfig) -> Result<Self, String> {
|
||||
pub fn encode(text: &str, config: QrConfig) -> Result<Self, QrError> {
|
||||
// 1. 分段
|
||||
let segments = segment_text(text);
|
||||
if segments.is_empty() {
|
||||
return Err("输入为空".into());
|
||||
return Err(QrError::EmptyInput);
|
||||
}
|
||||
|
||||
// 2. 确定版本
|
||||
let version = match config.version {
|
||||
VersionMode::Fixed(v) => Version::new(v).ok_or("无效版本号 (1-40)")?,
|
||||
VersionMode::Fixed(v) => Version::new(v).ok_or(QrError::InvalidVersion(v))?,
|
||||
VersionMode::Auto => {
|
||||
let mut selected = None;
|
||||
for v in 1..=40 {
|
||||
@@ -163,7 +154,7 @@ impl QrCode {
|
||||
break;
|
||||
}
|
||||
}
|
||||
selected.ok_or("数据过长,超出 QR 码最大容量".to_string())?
|
||||
selected.ok_or(QrError::DataTooLong)?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,7 +169,7 @@ impl QrCode {
|
||||
for _ in 0..binfo.count {
|
||||
let end = pos + binfo.data_codewords as usize;
|
||||
if end > data.len() {
|
||||
return Err("内部错误: 数据码字不足".into());
|
||||
return Err(QrError::Internal("数据码字不足".into()));
|
||||
}
|
||||
blocks.push(data[pos..end].to_vec());
|
||||
pos = end;
|
||||
@@ -213,17 +204,12 @@ impl QrCode {
|
||||
place_version_info(&mut final_matrix, ver_info);
|
||||
}
|
||||
|
||||
let fg_color = parse_hex_color(config.fg_color.as_deref().unwrap_or("#000000"))?;
|
||||
let bg_color = parse_hex_color(config.bg_color.as_deref().unwrap_or("#FFFFFF"))?;
|
||||
|
||||
Ok(QrCode {
|
||||
version,
|
||||
level: config.level,
|
||||
mask: best_idx,
|
||||
matrix: final_matrix,
|
||||
margin: config.margin,
|
||||
fg_color,
|
||||
bg_color,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,18 +235,25 @@ impl QrCode {
|
||||
|
||||
/// 导出为 SVG 字符串
|
||||
///
|
||||
/// SVG 内含 `viewBox`、使用 QrCode 的前/背景色。
|
||||
/// `logo` 可选的 logo 图片字节,会以 base64 嵌入 SVG 中央。
|
||||
/// `fg` 前景色("#RRGGBB"),默认 "#000000"。
|
||||
/// `bg` 背景色("#RRGGBB"),默认 "#FFFFFF"。
|
||||
///
|
||||
/// ```rust
|
||||
/// use qr_core::qr::{QrCode, QrConfig};
|
||||
///
|
||||
/// let qr = QrCode::encode("test", QrConfig::default()).unwrap();
|
||||
/// let svg = qr.to_svg(None);
|
||||
/// let svg = qr.to_svg(None, None, None);
|
||||
/// assert!(svg.starts_with("<svg"));
|
||||
/// ```
|
||||
pub fn to_svg(&self, logo: Option<&[u8]>) -> String {
|
||||
crate::render::svg::render_svg(self, logo)
|
||||
pub fn to_svg(&self, logo: Option<&[u8]>, fg: Option<&str>, bg: Option<&str>) -> String {
|
||||
let fg_color = fg
|
||||
.and_then(|c| parse_hex_color(c).ok())
|
||||
.unwrap_or([0, 0, 0]);
|
||||
let bg_color = bg
|
||||
.and_then(|c| parse_hex_color(c).ok())
|
||||
.unwrap_or([255, 255, 255]);
|
||||
crate::render::svg::render_svg(self, logo, &fg_color, &bg_color)
|
||||
}
|
||||
|
||||
/// 导出为终端 ASCII 文本
|
||||
@@ -276,24 +269,35 @@ impl QrCode {
|
||||
/// `module_size` 控制每个模块的像素大小(2~20)。
|
||||
/// `format` 输出格式,默认为 Png。
|
||||
/// `logo` 可选的 logo 图片字节。
|
||||
/// `fg` 前景色,默认 "#000000"。`bg` 背景色,默认 "#FFFFFF"。
|
||||
///
|
||||
/// ```rust
|
||||
/// use qr_core::qr::{QrCode, QrConfig};
|
||||
///
|
||||
/// let qr = QrCode::encode("test", QrConfig::default()).unwrap();
|
||||
/// let bytes = qr.to_image_bytes(4, None, None).unwrap();
|
||||
/// let bytes = qr.to_image_bytes(4, None, None, 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> {
|
||||
fg: Option<&str>,
|
||||
bg: Option<&str>,
|
||||
) -> Result<Vec<u8>, QrError> {
|
||||
let fg_color = fg
|
||||
.and_then(|c| parse_hex_color(c).ok())
|
||||
.unwrap_or([0, 0, 0]);
|
||||
let bg_color = bg
|
||||
.and_then(|c| parse_hex_color(c).ok())
|
||||
.unwrap_or([255, 255, 255]);
|
||||
crate::render::image::render_image(
|
||||
self,
|
||||
module_size,
|
||||
format.unwrap_or(crate::render::image::OutputFormat::Png),
|
||||
logo,
|
||||
&fg_color,
|
||||
&bg_color,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,8 +306,8 @@ impl QrCode {
|
||||
&self,
|
||||
module_size: u8,
|
||||
logo: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, image::ImageError> {
|
||||
self.to_image_bytes(module_size, logo, None)
|
||||
) -> Result<Vec<u8>, QrError> {
|
||||
self.to_image_bytes(module_size, logo, None, None, None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,32 +315,41 @@ impl QrCode {
|
||||
///
|
||||
/// 支持格式: "#RGB", "#RRGGBB"
|
||||
/// 无效格式返回 Err
|
||||
fn parse_hex_color(s: &str) -> Result<[u8; 3], String> {
|
||||
pub(crate) fn parse_hex_color(s: &str) -> Result<[u8; 3], QrError> {
|
||||
let s = s.trim();
|
||||
if !s.starts_with('#') {
|
||||
return Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s));
|
||||
return Err(QrError::InvalidColor(s.to_string()));
|
||||
}
|
||||
let hex = &s[1..];
|
||||
match hex.len() {
|
||||
3 => {
|
||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
// #RGB → 每个分量乘以 17 扩展为 #RRGGBB(避免 String 分配)
|
||||
// SAFETY: hex 来自 `s[1..]`,已通过 starts_with('#') 检查,且只含 ASCII hex 字符
|
||||
let bytes = hex.as_bytes();
|
||||
let r = u8::from_str_radix(unsafe {
|
||||
std::str::from_utf8_unchecked(&[bytes[0], bytes[0]])
|
||||
}, 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
let g = u8::from_str_radix(unsafe {
|
||||
std::str::from_utf8_unchecked(&[bytes[1], bytes[1]])
|
||||
}, 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
let b = u8::from_str_radix(unsafe {
|
||||
std::str::from_utf8_unchecked(&[bytes[2], bytes[2]])
|
||||
}, 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
Ok([r, g, b])
|
||||
}
|
||||
6 => {
|
||||
let r =
|
||||
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let g =
|
||||
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let b =
|
||||
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let r = u8::from_str_radix(&hex[0..2], 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16)
|
||||
.map_err(|_| QrError::InvalidColor(s.to_string()))?;
|
||||
Ok([r, g, b])
|
||||
}
|
||||
_ => Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)),
|
||||
_ => Err(QrError::InvalidColor(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,15 +409,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_color_qr() {
|
||||
let config = QrConfig {
|
||||
fg_color: Some("#FF0000".into()),
|
||||
bg_color: Some("#0000FF".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let qr = QrCode::encode("COLOR TEST", config).unwrap();
|
||||
assert_eq!(qr.fg_color, [255, 0, 0]);
|
||||
assert_eq!(qr.bg_color, [0, 0, 255]);
|
||||
let svg = qr.to_svg(None);
|
||||
let qr = QrCode::encode("COLOR TEST", QrConfig::default()).unwrap();
|
||||
let svg = qr.to_svg(None, Some("#FF0000"), Some("#0000FF"));
|
||||
assert!(svg.contains("#FF0000"));
|
||||
assert!(svg.contains("#0000FF"));
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ fn fill_module(
|
||||
}
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), crate::error::QrError> {
|
||||
let logo = image::load_from_memory(logo_bytes).map_err(crate::error::QrError::Image)?;
|
||||
let logo = logo.to_rgba8();
|
||||
let img_w = img.width();
|
||||
let img_h = img.height();
|
||||
@@ -102,7 +102,9 @@ pub fn render_image(
|
||||
module_size: u8,
|
||||
format: OutputFormat,
|
||||
logo: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, image::ImageError> {
|
||||
fg: &[u8; 3],
|
||||
bg: &[u8; 3],
|
||||
) -> Result<Vec<u8>, crate::error::QrError> {
|
||||
let matrix_size = qr.size() as u32;
|
||||
let margin = qr.margin as u32;
|
||||
let total_size = matrix_size + 2 * margin;
|
||||
@@ -123,15 +125,7 @@ pub fn render_image(
|
||||
} else {
|
||||
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, fg, bg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-11
@@ -1,18 +1,12 @@
|
||||
use crate::qr::QrCode;
|
||||
|
||||
pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String {
|
||||
pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>, fg: &[u8; 3], bg: &[u8; 3]) -> String {
|
||||
let matrix_size = qr.size() as u32;
|
||||
let margin = qr.margin as u32;
|
||||
let total = matrix_size + 2 * margin;
|
||||
|
||||
let fg = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
qr.fg_color[0], qr.fg_color[1], qr.fg_color[2]
|
||||
);
|
||||
let bg = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
qr.bg_color[0], qr.bg_color[1], qr.bg_color[2]
|
||||
);
|
||||
let fg_hex = format!("#{:02X}{:02X}{:02X}", fg[0], fg[1], fg[2]);
|
||||
let bg_hex = format!("#{:02X}{:02X}{:02X}", bg[0], bg[1], bg[2]);
|
||||
|
||||
let dark_count = qr
|
||||
.modules()
|
||||
@@ -25,14 +19,14 @@ pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String {
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total}" height="{total}" viewBox="0 0 {total} {total}">"#
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
r#"<rect width="{total}" height="{total}" fill="{bg}"/>"#
|
||||
r#"<rect width="{total}" height="{total}" fill="{bg_hex}"/>"#
|
||||
));
|
||||
|
||||
for y in 0..matrix_size {
|
||||
for x in 0..matrix_size {
|
||||
if qr.modules()[y as usize][x as usize] {
|
||||
svg.push_str(&format!(
|
||||
r#"<rect x="{}" y="{}" width="1" height="1" fill="{fg}"/>"#,
|
||||
r#"<rect x="{}" y="{}" width="1" height="1" fill="{fg_hex}"/>"#,
|
||||
x + margin,
|
||||
y + margin
|
||||
));
|
||||
|
||||
@@ -24,23 +24,24 @@ pub fn build_vcard_text(
|
||||
) -> String {
|
||||
let mut s =
|
||||
format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}");
|
||||
use std::fmt::Write;
|
||||
if !title.is_empty() {
|
||||
s.push_str(&format!("\nTITLE:{title}"));
|
||||
write!(s, "\nTITLE:{title}").unwrap();
|
||||
}
|
||||
if !address.is_empty() {
|
||||
s.push_str(&format!("\nADR:{address}"));
|
||||
write!(s, "\nADR:{address}").unwrap();
|
||||
}
|
||||
if !url.is_empty() {
|
||||
s.push_str(&format!("\nURL:{url}"));
|
||||
write!(s, "\nURL:{url}").unwrap();
|
||||
}
|
||||
if !birthday.is_empty() {
|
||||
s.push_str(&format!("\nBDAY:{birthday}"));
|
||||
write!(s, "\nBDAY:{birthday}").unwrap();
|
||||
}
|
||||
if !note.is_empty() {
|
||||
s.push_str(&format!("\nNOTE:{note}"));
|
||||
write!(s, "\nNOTE:{note}").unwrap();
|
||||
}
|
||||
if !photo.is_empty() {
|
||||
s.push_str(&format!("\nPHOTO:{photo}"));
|
||||
write!(s, "\nPHOTO:{photo}").unwrap();
|
||||
}
|
||||
s.push_str("\nEND:VCARD");
|
||||
s
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::error::QrError;
|
||||
use serde::Serialize;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// QR 码纠错级别
|
||||
@@ -38,6 +40,20 @@ impl EcLevel {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for EcLevel {
|
||||
type Err = QrError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"L" => Ok(EcLevel::L),
|
||||
"M" => Ok(EcLevel::M),
|
||||
"Q" => Ok(EcLevel::Q),
|
||||
"H" => Ok(EcLevel::H),
|
||||
_ => Err(QrError::InvalidVersion(0)), // 复用变体
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// QR 码版本号(1~40)
|
||||
///
|
||||
/// 版本决定 QR 码的物理尺寸:`side = 17 + version × 4` 模块。
|
||||
|
||||
@@ -193,11 +193,11 @@ fn test_format_info_written() {
|
||||
#[test]
|
||||
fn test_svg_valid_structure() {
|
||||
let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap();
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
// SVG 应有正确的结构
|
||||
assert!(svg.starts_with("<svg"), "SVG 应以 <svg 开头");
|
||||
assert!(svg.contains("rect"), "SVG 应包含 rect 元素");
|
||||
assert!(svg.contains("fill=\"black\""), "SVG 暗模块应是黑色");
|
||||
assert!(svg.contains("fill=\"#000000\""), "SVG 暗模块应是黑色");
|
||||
assert!(
|
||||
svg.ends_with("</svg>\n") || svg.ends_with("</svg>"),
|
||||
"SVG 应以 </svg> 结尾"
|
||||
@@ -208,7 +208,6 @@ fn test_svg_valid_structure() {
|
||||
fn test_quiet_zone_is_white() {
|
||||
let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap();
|
||||
let m = qr.modules();
|
||||
let s = qr.size() as usize;
|
||||
// 左上角分隔符区域 (7,0..7) 和 (0..7,7) 应为白色
|
||||
for i in 0..8usize {
|
||||
assert!(!m[7][i], "定位分隔符 (7,{}) 应为白色", i);
|
||||
@@ -313,10 +312,10 @@ fn test_empty_input_fails() {
|
||||
#[test]
|
||||
fn test_svg_output() {
|
||||
let qr = QrCode::encode("TEST", QrConfig::default()).unwrap();
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
assert!(svg.contains("fill=\"black\""));
|
||||
assert!(svg.contains("fill=\"#000000\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,7 +355,7 @@ fn test_margin_is_included_in_dimensions() {
|
||||
let qr = QrCode::encode("MARGIN TEST", config).unwrap();
|
||||
|
||||
// SVG 的总宽度应该包含 margin
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
let matrix_size = qr.size() as u32;
|
||||
let expected_total = matrix_size + 2 * 2u32;
|
||||
assert!(svg.contains(&format!("width=\"{}\"", expected_total)));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! QRGen 基础示例:生成 QR 码并导出为多种格式
|
||||
//!
|
||||
//! 运行: `cargo run --example basic_qr`
|
||||
/// QRGen 基础示例:生成 QR 码并导出为多种格式
|
||||
///
|
||||
/// 运行: `cargo run --example basic_qr`
|
||||
|
||||
use qr_core::qr::{QrCode, QrConfig};
|
||||
|
||||
@@ -19,11 +19,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", qr.to_ascii(false));
|
||||
|
||||
// 导出 PNG
|
||||
qr.to_png_bytes(8, None)?;
|
||||
println!("\nPNG 生成成功");
|
||||
let _png = qr.to_png_bytes(8, None)?;
|
||||
println!("\nPNG 生成成功 ({} 字节)", _png.len());
|
||||
|
||||
// 导出 SVG
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
println!("SVG 长度: {} 字节", svg.len());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! QRGen 自定义配置:强制指定版本、模块大小
|
||||
//!
|
||||
//! 运行: `cargo run --example custom_config`
|
||||
/// QRGen 自定义配置:强制指定版本、模块大小
|
||||
///
|
||||
/// 运行: `cargo run --example custom_config`
|
||||
|
||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||
use qr_core::version::EcLevel;
|
||||
@@ -11,6 +11,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
level: EcLevel::Q,
|
||||
version: VersionMode::Fixed(10),
|
||||
margin: 4,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = QrCode::encode("固定版本 10 的 QR 码", config)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! QRGen 高纠错示例:生成可抵抗 30% 损坏的 QR 码
|
||||
//!
|
||||
//! 运行: `cargo run --example high_ecc`
|
||||
/// QRGen 高纠错示例:生成可抵抗 30% 损坏的 QR 码
|
||||
///
|
||||
/// 运行: `cargo run --example high_ecc`
|
||||
|
||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||
use qr_core::version::EcLevel;
|
||||
@@ -10,6 +10,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
level: EcLevel::H, // 30% 纠错能力
|
||||
version: VersionMode::Auto,
|
||||
margin: 6, // 更大的静区
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = QrCode::encode("重要数据 - High ECC", config)?;
|
||||
@@ -21,7 +22,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
qr.size()
|
||||
);
|
||||
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
println!("SVG 生成成功: {} 字节", svg.len());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -51,7 +51,7 @@ function AppLayout() {
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
<div className="h-24 border-t border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-3">
|
||||
<div className="h-36 border-t border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-3">
|
||||
<BottomInput />
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
function BottomInput() {
|
||||
const { state } = useQrState();
|
||||
const state = useQrState();
|
||||
|
||||
switch (state.mode) {
|
||||
case 'text':
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QrProvider, useQrState } from '../store/qrContext';
|
||||
import { QrProvider, useQrState, useQrDispatch } from '../store/qrContext';
|
||||
|
||||
describe('QrProvider + useQrState', () => {
|
||||
function useQr() {
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
return { state, dispatch };
|
||||
}
|
||||
|
||||
describe('QrProvider + split context', () => {
|
||||
it('provides default state', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
expect(result.current.state.mode).toBe('text');
|
||||
expect(result.current.state.config.level).toBe('M');
|
||||
expect(result.current.state.config.margin).toBe(4);
|
||||
@@ -23,7 +29,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_MODE', payload: 'wifi' }));
|
||||
expect(result.current.state.mode).toBe('wifi');
|
||||
});
|
||||
@@ -32,13 +38,8 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_FORM_DATA',
|
||||
payload: { text: 'hello' },
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_FORM_DATA', payload: { text: 'hello' } }));
|
||||
expect(result.current.state.formData.text).toBe('hello');
|
||||
});
|
||||
|
||||
@@ -46,13 +47,8 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_CONFIG',
|
||||
payload: { level: 'H' },
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_CONFIG', payload: { level: 'H' } }));
|
||||
expect(result.current.state.config.level).toBe('H');
|
||||
expect(result.current.state.config.margin).toBe(4); // unchanged
|
||||
});
|
||||
@@ -61,7 +57,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_PREVIEW',
|
||||
@@ -76,7 +72,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_HISTORY',
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* QR 编码文本构造工具 — 单元测试
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildWifiText,
|
||||
buildVCardText,
|
||||
buildEmailText,
|
||||
buildPhoneText,
|
||||
buildSmsText,
|
||||
buildEncodedText,
|
||||
} from '@/utils/qrText';
|
||||
|
||||
describe('buildWifiText', () => {
|
||||
it('构造 WPA WiFi 字符串', () => {
|
||||
const result = buildWifiText({
|
||||
ssid: 'MyWiFi',
|
||||
encryption: 'WPA',
|
||||
password: 'pass123',
|
||||
});
|
||||
expect(result).toBe('WIFI:T:WPA;S:MyWiFi;P:pass123;;');
|
||||
});
|
||||
|
||||
it('空 SSID 返回空字符串', () => {
|
||||
const result = buildWifiText({ ssid: '' });
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('隐藏网络标记正确', () => {
|
||||
const result = buildWifiText({
|
||||
ssid: 'HiddenNet',
|
||||
encryption: 'WPA2',
|
||||
password: 'secret',
|
||||
hidden: 'true',
|
||||
});
|
||||
expect(result).toBe('WIFI:T:WPA2;S:HiddenNet;P:secret;H:true;;');
|
||||
});
|
||||
|
||||
it('默认加密方式为 WPA', () => {
|
||||
const result = buildWifiText({ ssid: 'Test' });
|
||||
expect(result).toContain('T:WPA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVCardText', () => {
|
||||
it('构造完整 vCard', () => {
|
||||
const result = buildVCardText({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
email: 'zhangsan@example.com',
|
||||
company: '测试公司',
|
||||
address: '北京市',
|
||||
});
|
||||
expect(result).toContain('BEGIN:VCARD');
|
||||
expect(result).toContain('VERSION:3.0');
|
||||
expect(result).toContain('FN:张三');
|
||||
expect(result).toContain('TEL:13800138000');
|
||||
expect(result).toContain('EMAIL:zhangsan@example.com');
|
||||
expect(result).toContain('END:VCARD');
|
||||
});
|
||||
|
||||
it('空字段产生空值', () => {
|
||||
const result = buildVCardText({});
|
||||
expect(result).toBe('BEGIN:VCARD\nVERSION:3.0\nFN:\nTEL:\nEMAIL:\nORG:\nADR:\nEND:VCARD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEmailText', () => {
|
||||
it('构造 mailto 链接', () => {
|
||||
const result = buildEmailText({
|
||||
to: 'test@example.com',
|
||||
subject: 'Hello',
|
||||
body: 'Test body',
|
||||
});
|
||||
expect(result).toContain('mailto:test@example.com');
|
||||
expect(result).toContain('subject=');
|
||||
expect(result).toContain('body=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhoneText', () => {
|
||||
it('构造电话链接', () => {
|
||||
expect(buildPhoneText({ number: '13800138000' })).toBe('tel:13800138000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSmsText', () => {
|
||||
it('构造短信链接', () => {
|
||||
const result = buildSmsText({ number: '13800138000', message: 'Hi' });
|
||||
expect(result).toBe('smsto:13800138000:Hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEncodedText', () => {
|
||||
it('url 模式返回 url 字段', () => {
|
||||
const result = buildEncodedText('url', { url: 'https://example.com' });
|
||||
expect(result).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('wifi 模式委托给 buildWifiText', () => {
|
||||
const result = buildEncodedText('wifi', { ssid: 'Test', encryption: 'WPA' });
|
||||
expect(result).toContain('WIFI:T:WPA;S:Test');
|
||||
});
|
||||
|
||||
it('未知模式返回 text 字段', () => {
|
||||
const result = buildEncodedText('unknown', { text: 'raw text' });
|
||||
expect(result).toBe('raw text');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import type { QrConfig } from '../types';
|
||||
import { buildEncodedText } from '../utils/qrText';
|
||||
|
||||
export default function ExportPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [decodedText, setDecodedText] = useState<string | null>(null);
|
||||
@@ -62,8 +62,12 @@ export default function ExportPanel() {
|
||||
setExporting(false);
|
||||
return;
|
||||
}
|
||||
const qrText = await invoke<string>('build_qr_text', {
|
||||
mode: state.mode,
|
||||
formData: state.formData,
|
||||
});
|
||||
const bytes: number[] = await invoke('export_png', {
|
||||
text: buildEncodedText(state.mode, state.formData),
|
||||
text: qrText,
|
||||
level: state.config.level,
|
||||
margin: state.config.margin,
|
||||
moduleSize: state.config.moduleSize,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { type HistoryEntry } from '../types';
|
||||
import { persistHistory } from '../hooks/useQrEncode';
|
||||
import { persistHistory } from '../utils/storage';
|
||||
|
||||
const MODE_I18N: Record<string, string> = {
|
||||
text: 'mode.text',
|
||||
@@ -15,7 +15,8 @@ const MODE_I18N: Record<string, string> = {
|
||||
|
||||
export default function HistoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
|
||||
const handleClick = (entry: HistoryEntry) => {
|
||||
dispatch({ type: 'SET_MODE', payload: entry.mode });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { MODES } from '../types';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
@@ -14,7 +14,8 @@ const MODE_LABELS: Record<string, string> = {
|
||||
|
||||
export default function ModePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
|
||||
return (
|
||||
<div className="w-48 border-r border-gray-200 dark:border-gray-800 p-3 flex flex-col gap-1 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
|
||||
@@ -10,23 +10,38 @@ function svgToDataUrl(svg: string): string {
|
||||
return `data:image/svg+xml;base64,${btoa(binStr)}`;
|
||||
}
|
||||
|
||||
export default function QrPreview() {
|
||||
/** QR 码预览组件(纯展示,用 React.memo 避免不必要的重渲染) */
|
||||
const QrPreview = memo(function QrPreview() {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useQrState();
|
||||
const state = useQrState();
|
||||
|
||||
const svgDataUrl = useMemo(
|
||||
() => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null),
|
||||
[state.preview?.svg],
|
||||
);
|
||||
|
||||
const preview = state.preview;
|
||||
const containerCls = 'w-64 h-64 flex items-center justify-center bg-white rounded-xl shadow-sm';
|
||||
|
||||
// 错误状态
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-red-400">
|
||||
<div className={`${containerCls} border border-red-200 bg-red-50`}>
|
||||
<span className="text-sm text-center px-4">{state.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!svgDataUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<div className={`${containerCls} border border-gray-200`}>
|
||||
{state.loading ? (
|
||||
<span className="text-sm animate-pulse">{t('preview.loading')}</span>
|
||||
<span className="text-sm animate-pulse" role="status">
|
||||
{t('preview.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">{t('preview.empty')}</span>
|
||||
)}
|
||||
@@ -41,17 +56,25 @@ export default function QrPreview() {
|
||||
<div className={containerCls}>
|
||||
<img src={svgDataUrl} alt="QR Code" className="w-60 h-60" />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-400">
|
||||
{preview && (
|
||||
<div
|
||||
className="flex gap-3 text-xs text-gray-400"
|
||||
role="group"
|
||||
aria-label={t('preview.info')}
|
||||
>
|
||||
<span>
|
||||
{t('preview.version')} {state.preview!.version}
|
||||
{t('preview.version')} {preview.version}
|
||||
</span>
|
||||
<span>
|
||||
{state.preview!.size}×{state.preview!.size}
|
||||
{preview.size}×{preview.size}
|
||||
</span>
|
||||
<span>
|
||||
{t('preview.mask')} {state.preview!.mask}
|
||||
{t('preview.mask')} {preview.mask}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default QrPreview;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from './useQrEncode';
|
||||
import type { ModeType } from '../types';
|
||||
|
||||
/**
|
||||
* 通用模式表单 hook
|
||||
*
|
||||
* 封装模式组件中重复的 update 模式:
|
||||
* 1. 合并 formData
|
||||
* 2. dispatch SET_FORM_DATA
|
||||
* 3. 调用 Rust text_builder 构建 QR 文本
|
||||
* 4. 触达编码
|
||||
*/
|
||||
export function useModeForm(mode: ModeType) {
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = useCallback(
|
||||
async (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
try {
|
||||
const text = await invoke<string>('build_qr_text', { mode, formData: data });
|
||||
encode(text);
|
||||
} catch {
|
||||
/* 错误在 encode 中通过 SET_ERROR dispatch 处理 */
|
||||
}
|
||||
},
|
||||
[mode, state.formData, encode, dispatch],
|
||||
);
|
||||
|
||||
return { formData: state.formData, update };
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Store } from '@tauri-apps/plugin-store';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { persistHistory } from '../utils/storage';
|
||||
import type { HistoryEntry, ModeType } from '../types';
|
||||
|
||||
const HISTORY_KEY = 'qr-history';
|
||||
const STORE_FILE = 'history.json';
|
||||
|
||||
/** 缓存的 Store 实例,避免每次编码都重新加载 */
|
||||
let storeCache: Promise<Store> | null = null;
|
||||
function getStore(): Promise<Store> {
|
||||
if (!storeCache) {
|
||||
storeCache = Store.load(STORE_FILE);
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
interface QrResponse {
|
||||
svg: string;
|
||||
version: number;
|
||||
@@ -31,37 +19,19 @@ function sanitizeContent(mode: ModeType, content: string): string {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化整个历史列表到 store
|
||||
* 作为内存状态的唯一持久化出口
|
||||
*/
|
||||
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
await store.set(HISTORY_KEY, history);
|
||||
await store.save();
|
||||
} catch {
|
||||
/* store 不可用时静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 store 加载历史记录(应用启动时调用) */
|
||||
export async function loadHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useQrEncode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 用 ref 持有最新值,避免 encode 回调依赖 formData/history
|
||||
const modeRef = useRef(state.mode);
|
||||
modeRef.current = state.mode;
|
||||
const formDataRef = useRef(state.formData);
|
||||
formDataRef.current = state.formData;
|
||||
const historyRef = useRef(state.history);
|
||||
historyRef.current = state.history;
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
@@ -72,10 +42,12 @@ export function useQrEncode() {
|
||||
(text: string) => {
|
||||
if (!text.trim()) {
|
||||
dispatch({ type: 'SET_PREVIEW', payload: null });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
@@ -87,30 +59,26 @@ export function useQrEncode() {
|
||||
});
|
||||
dispatch({ type: 'SET_PREVIEW', payload: result });
|
||||
|
||||
// 保存到历史(内存 + 持久化)
|
||||
const entryId = Date.now().toString();
|
||||
const currentMode = modeRef.current;
|
||||
const entry: HistoryEntry = {
|
||||
id: entryId,
|
||||
mode: currentMode,
|
||||
content: sanitizeContent(currentMode, text),
|
||||
id: Date.now().toString(),
|
||||
mode: modeRef.current,
|
||||
content: sanitizeContent(modeRef.current, text),
|
||||
timestamp: Date.now(),
|
||||
formData: { ...state.formData },
|
||||
formData: { ...formDataRef.current },
|
||||
};
|
||||
dispatch({ type: 'ADD_HISTORY', payload: entry });
|
||||
|
||||
// 从内存状态持久化(避免 store 读写竞态)
|
||||
// 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表
|
||||
// 确保持久化的数据与内存一致
|
||||
persistHistory([entry, ...state.history].slice(0, 50));
|
||||
} catch {
|
||||
// 编码失败时清空预览
|
||||
persistHistory([entry, ...historyRef.current].slice(0, 50));
|
||||
} catch (e) {
|
||||
dispatch({ type: 'SET_PREVIEW', payload: null });
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: e instanceof Error ? e.message : '编码失败,请检查输入内容',
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
[state.config.level, state.config.margin, state.formData, state.history, dispatch],
|
||||
[state.config.level, state.config.margin, dispatch],
|
||||
);
|
||||
|
||||
return { encode, persistHistory };
|
||||
return { encode };
|
||||
}
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildEmailText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function EmailMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildEmailText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('email');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder={t('email.to')}
|
||||
value={state.formData.to || ''}
|
||||
value={formData.to || ''}
|
||||
onChange={(e) => update('to', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('email.subject')}
|
||||
value={state.formData.subject || ''}
|
||||
value={formData.subject || ''}
|
||||
onChange={(e) => update('subject', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('email.body')}
|
||||
value={state.formData.body || ''}
|
||||
value={formData.body || ''}
|
||||
onChange={(e) => update('body', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildPhoneText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function PhoneMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (number: string) => {
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: { number } });
|
||||
encode(buildPhoneText({ number }));
|
||||
};
|
||||
const { formData, update } = useModeForm('phone');
|
||||
|
||||
return (
|
||||
<input
|
||||
placeholder={t('phone.placeholder')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
onChange={(e) => update(e.target.value)}
|
||||
placeholder={t('phone.placeholder')}
|
||||
value={formData.number || ''}
|
||||
onChange={(e) => update('number', e.target.value)}
|
||||
className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600 focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildSmsText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function SmsMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildSmsText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('sms');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder={t('sms.number')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
placeholder={t('sms.number')}
|
||||
value={formData.number || ''}
|
||||
onChange={(e) => update('number', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('sms.message')}
|
||||
value={state.formData.message || ''}
|
||||
value={formData.message || ''}
|
||||
onChange={(e) => update('message', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function TextMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function UrlMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const handleChange = (url: string) => {
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildVCardText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'name', i18n: 'vcard.name' },
|
||||
{ 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' },
|
||||
];
|
||||
'name',
|
||||
'phone',
|
||||
'email',
|
||||
'company',
|
||||
'title',
|
||||
'address',
|
||||
'vcardUrl',
|
||||
'birthday',
|
||||
'note',
|
||||
'photo',
|
||||
] as const;
|
||||
|
||||
export default function VCardMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildVCardText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('vcard');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
{FIELDS.map((f) => (
|
||||
<div className="grid grid-cols-2 gap-2 px-4 py-1 h-full overflow-y-auto content-start">
|
||||
{FIELDS.map((key) => (
|
||||
<input
|
||||
key={f.key}
|
||||
placeholder={t(f.i18n)}
|
||||
value={state.formData[f.key] || ''}
|
||||
onChange={(e) => update(f.key, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
key={key}
|
||||
placeholder={t(`vcard.${key}`)}
|
||||
value={formData[key] || ''}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
className="px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 text-xs bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildWifiText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function WifiMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
/** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildWifiText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('wifi');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder="SSID"
|
||||
value={state.formData.ssid || ''}
|
||||
value={formData.ssid || ''}
|
||||
onChange={(e) => update('ssid', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('wifi.password')}
|
||||
type="password"
|
||||
value={state.formData.password || ''}
|
||||
value={formData.password || ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<select
|
||||
value={state.formData.encryption || 'WPA'}
|
||||
value={formData.encryption || 'WPA'}
|
||||
onChange={(e) => update('encryption', e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
>
|
||||
<option value="WPA">WPA/WPA2</option>
|
||||
<option value="WEP">WEP</option>
|
||||
<option value="nopass">无{t('wifi.password')}</option>
|
||||
<option value="nopass">{t('wifi.noPassword')}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.formData.hidden === 'true'}
|
||||
checked={formData.hidden === 'true'}
|
||||
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
{t('wifi.hidden')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
|
||||
import { loadHistory } from '../hooks/useQrEncode';
|
||||
import { loadHistory } from '../utils/storage';
|
||||
import type { QrState, QrAction } from '../types';
|
||||
|
||||
const initialState: QrState = {
|
||||
@@ -9,20 +9,23 @@ const initialState: QrState = {
|
||||
preview: null,
|
||||
history: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function qrReducer(state: QrState, action: QrAction): QrState {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.payload, formData: {}, preview: null };
|
||||
return { ...state, mode: action.payload, formData: {}, preview: null, error: null };
|
||||
case 'SET_FORM_DATA':
|
||||
return { ...state, formData: action.payload };
|
||||
return { ...state, formData: action.payload, error: null };
|
||||
case 'SET_CONFIG':
|
||||
return { ...state, config: { ...state.config, ...action.payload } };
|
||||
case 'SET_PREVIEW':
|
||||
return { ...state, preview: action.payload, loading: false };
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload };
|
||||
case 'SET_HISTORY':
|
||||
return { ...state, history: action.payload };
|
||||
case 'ADD_HISTORY':
|
||||
@@ -36,15 +39,14 @@ function qrReducer(state: QrState, action: QrAction): QrState {
|
||||
}
|
||||
}
|
||||
|
||||
const QrContext = createContext<{
|
||||
state: QrState;
|
||||
dispatch: React.Dispatch<QrAction>;
|
||||
} | null>(null);
|
||||
/** 只读 state context — 只有 state 变化的组件需要订阅 */
|
||||
const StateContext = createContext<QrState | null>(null);
|
||||
/** 只写 dispatch context — 只需要 dispatch 的组件不因 state 变化而重渲染 */
|
||||
const DispatchContext = createContext<React.Dispatch<QrAction> | null>(null);
|
||||
|
||||
export function QrProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(qrReducer, initialState);
|
||||
|
||||
// 启动时从 store 加载持久化的历史记录
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const history = await loadHistory();
|
||||
@@ -52,11 +54,23 @@ export function QrProvider({ children }: { children: ReactNode }) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <QrContext.Provider value={{ state, dispatch }}>{children}</QrContext.Provider>;
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
|
||||
</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 订阅 state(需要读取 state 的组件使用) */
|
||||
export function useQrState() {
|
||||
const ctx = useContext(QrContext);
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) throw new Error('useQrState must be inside QrProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 获取 dispatch(只需要写入的组件使用,不触发 state 重渲染) */
|
||||
export function useQrDispatch() {
|
||||
const ctx = useContext(DispatchContext);
|
||||
if (!ctx) throw new Error('useQrDispatch must be inside QrProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface QrState {
|
||||
preview: QrPreview | null;
|
||||
history: HistoryEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type QrAction =
|
||||
@@ -37,19 +38,10 @@ export type QrAction =
|
||||
| { type: 'SET_CONFIG'; payload: Partial<QrConfig> }
|
||||
| { type: 'SET_PREVIEW'; payload: QrPreview | null }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_HISTORY'; payload: HistoryEntry[] }
|
||||
| { type: 'ADD_HISTORY'; payload: HistoryEntry }
|
||||
| { type: 'REMOVE_HISTORY'; payload: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export const MODE_LABELS: Record<ModeType, string> = {
|
||||
text: '文本',
|
||||
url: 'URL',
|
||||
wifi: 'WiFi',
|
||||
vcard: 'vCard',
|
||||
email: 'Email',
|
||||
phone: '电话',
|
||||
sms: 'SMS',
|
||||
};
|
||||
|
||||
export const MODES: ModeType[] = ['text', 'url', 'wifi', 'vcard', 'email', 'phone', 'sms'];
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* QR 编码文本构造工具
|
||||
* 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑
|
||||
*/
|
||||
|
||||
/** 构造 WiFi 连接字符串 */
|
||||
export function buildWifiText(formData: Record<string, string>): string {
|
||||
const ssid = formData.ssid || '';
|
||||
if (!ssid) return '';
|
||||
const encryption = formData.encryption || 'WPA';
|
||||
const password = formData.password || '';
|
||||
// hidden 存储为字符串 'true'/'false',保留 boolean 语义
|
||||
const hidden = formData.hidden === 'true' ? 'H:true;' : '';
|
||||
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
|
||||
}
|
||||
|
||||
/** 构造 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 || '';
|
||||
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 链接 */
|
||||
export function buildEmailText(formData: Record<string, string>): string {
|
||||
const to = formData.to || '';
|
||||
const subject = encodeURIComponent(formData.subject || '');
|
||||
const body = encodeURIComponent(formData.body || '');
|
||||
return `mailto:${to}?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
/** 构造电话链接 */
|
||||
export function buildPhoneText(formData: Record<string, string>): string {
|
||||
return `tel:${formData.number || ''}`;
|
||||
}
|
||||
|
||||
/** 构造短信链接 */
|
||||
export function buildSmsText(formData: Record<string, string>): string {
|
||||
return `smsto:${formData.number || ''}:${formData.message || ''}`;
|
||||
}
|
||||
|
||||
/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */
|
||||
export function buildEncodedText(mode: string, formData: Record<string, string>): string {
|
||||
switch (mode) {
|
||||
case 'url':
|
||||
return formData.url || '';
|
||||
case 'wifi':
|
||||
return buildWifiText(formData);
|
||||
case 'vcard':
|
||||
return buildVCardText(formData);
|
||||
case 'email':
|
||||
return buildEmailText(formData);
|
||||
case 'phone':
|
||||
return buildPhoneText(formData);
|
||||
case 'sms':
|
||||
return buildSmsText(formData);
|
||||
default:
|
||||
return formData.text || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Store } from '@tauri-apps/plugin-store';
|
||||
import type { HistoryEntry } from '../types';
|
||||
|
||||
const HISTORY_KEY = 'qr-history';
|
||||
const STORE_FILE = 'history.json';
|
||||
|
||||
/** 缓存的 Store 实例,避免每次编码都重新加载 */
|
||||
let storeCache: Promise<Store> | null = null;
|
||||
function getStore(): Promise<Store> {
|
||||
if (!storeCache) {
|
||||
storeCache = Store.load(STORE_FILE);
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化整个历史列表到 store
|
||||
* 作为内存状态的唯一持久化出口
|
||||
*/
|
||||
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
await store.set(HISTORY_KEY, history);
|
||||
await store.save();
|
||||
} catch {
|
||||
/* store 不可用时静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 store 加载历史记录(应用启动时调用) */
|
||||
export async function loadHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+75
-33
@@ -1,6 +1,7 @@
|
||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||
use qr_core::version::EcLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// QR 编码响应
|
||||
@@ -26,31 +27,31 @@ struct AppState {
|
||||
history: Mutex<Vec<HistoryEntry>>,
|
||||
}
|
||||
|
||||
/// 解析纠错级别 + 构造 QrConfig(共享辅助函数)
|
||||
fn parse_level_and_config(level: &str, margin: u8) -> Result<(EcLevel, QrConfig), String> {
|
||||
let ec_level = EcLevel::from_str(level)
|
||||
.map_err(|e| format!("无效纠错级别: {e}"))?;
|
||||
let config = QrConfig {
|
||||
level: ec_level,
|
||||
version: VersionMode::Auto,
|
||||
margin,
|
||||
};
|
||||
Ok((ec_level, config))
|
||||
}
|
||||
|
||||
/// 编码 QR 码,返回 SVG + 元信息
|
||||
#[tauri::command]
|
||||
fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, String> {
|
||||
if margin > 100 {
|
||||
return Err("边距过大(最大 100)".into());
|
||||
}
|
||||
if text.len() > 10_000 {
|
||||
return Err("文本过长(最大 10000 字符)".into());
|
||||
}
|
||||
|
||||
let ec_level = match level.to_uppercase().as_str() {
|
||||
"L" => EcLevel::L,
|
||||
"M" => EcLevel::M,
|
||||
"Q" => EcLevel::Q,
|
||||
"H" => EcLevel::H,
|
||||
_ => return Err(format!("无效纠错级别: {}", level)),
|
||||
};
|
||||
|
||||
let config = QrConfig {
|
||||
level: ec_level,
|
||||
version: VersionMode::Auto,
|
||||
margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_ec_level, config) = parse_level_and_config(&level, margin)?;
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||
|
||||
let svg = qr.to_svg(None);
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
|
||||
Ok(QrResponse {
|
||||
svg,
|
||||
@@ -66,22 +67,14 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
|
||||
if margin > 100 {
|
||||
return Err("边距过大(最大 100)".into());
|
||||
}
|
||||
if !(1..=50).contains(&module_size) {
|
||||
return Err("模块大小需在 1-50 之间".into());
|
||||
}
|
||||
if text.len() > 10_000 {
|
||||
return Err("文本过长(最大 10000 字符)".into());
|
||||
}
|
||||
|
||||
let ec_level = match level.to_uppercase().as_str() {
|
||||
"L" => EcLevel::L,
|
||||
"M" => EcLevel::M,
|
||||
"Q" => EcLevel::Q,
|
||||
"H" => EcLevel::H,
|
||||
_ => return Err(format!("无效纠错级别: {}", level)),
|
||||
};
|
||||
|
||||
let config = QrConfig {
|
||||
level: ec_level,
|
||||
version: VersionMode::Auto,
|
||||
margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_ec_level, config) = parse_level_and_config(&level, margin)?;
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||
|
||||
qr.to_png_bytes(module_size, None)
|
||||
@@ -109,7 +102,9 @@ fn load_history(state: tauri::State<AppState>) -> Result<Vec<HistoryEntry>, Stri
|
||||
/// 解码 QR 码图片,返回文本内容
|
||||
#[tauri::command]
|
||||
fn decode_qr(image_bytes: Vec<u8>) -> Result<String, String> {
|
||||
qr_core::decoder::decode_image(&image_bytes).map(|r| r.text)
|
||||
qr_core::decoder::decode_image(&image_bytes)
|
||||
.map(|r| r.text)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 清空历史记录
|
||||
@@ -120,6 +115,52 @@ fn clear_history(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构建各模式的格式化 QR 文本(替代前端重复实现)
|
||||
#[tauri::command]
|
||||
fn build_qr_text(
|
||||
mode: String,
|
||||
form_data: std::collections::HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let get = |k: &str| form_data.get(k).map(|v| v.as_str()).unwrap_or("");
|
||||
match mode.as_str() {
|
||||
"text" => Ok(get("text").to_string()),
|
||||
"url" => Ok(get("url").to_string()),
|
||||
"wifi" => {
|
||||
let enc = get("encryption");
|
||||
let enc = if enc.is_empty() { "WPA" } else { enc };
|
||||
Ok(qr_core::text_builder::build_wifi_text(
|
||||
get("ssid"),
|
||||
get("password"),
|
||||
enc,
|
||||
get("hidden") == "true",
|
||||
))
|
||||
}
|
||||
"vcard" => Ok(qr_core::text_builder::build_vcard_text(
|
||||
get("name"),
|
||||
get("phone"),
|
||||
get("email"),
|
||||
get("company"),
|
||||
get("address"),
|
||||
get("title"),
|
||||
get("vcardUrl"),
|
||||
get("birthday"),
|
||||
get("note"),
|
||||
get("photo"),
|
||||
)),
|
||||
"email" => Ok(qr_core::text_builder::build_email_text(
|
||||
get("to"),
|
||||
get("subject"),
|
||||
get("body"),
|
||||
)),
|
||||
"phone" => Ok(qr_core::text_builder::build_phone_text(get("number"))),
|
||||
"sms" => Ok(qr_core::text_builder::build_sms_text(
|
||||
get("number"),
|
||||
get("message"),
|
||||
)),
|
||||
_ => Err(format!("未知模式: {mode}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -134,6 +175,7 @@ pub fn run() {
|
||||
encode_qr,
|
||||
export_png,
|
||||
decode_qr,
|
||||
build_qr_text,
|
||||
save_history,
|
||||
load_history,
|
||||
clear_history,
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ axum = { version = "0.8", features = ["multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "limit", "set-header"] }
|
||||
|
||||
+91
-31
@@ -8,8 +8,28 @@ use axum::{
|
||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||
use qr_core::version::EcLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use tower_http::{
|
||||
cors::CorsLayer,
|
||||
limit::RequestBodyLimitLayer,
|
||||
set_header::SetResponseHeaderLayer,
|
||||
};
|
||||
|
||||
/// GET /api/qr 查询参数
|
||||
/// POST /api/qr JSON 请求体
|
||||
#[derive(Deserialize)]
|
||||
struct QrRequest {
|
||||
text: String,
|
||||
#[serde(default = "default_level")]
|
||||
level: String,
|
||||
#[serde(default = "default_margin")]
|
||||
margin: u8,
|
||||
#[serde(default = "default_size")]
|
||||
size: u8,
|
||||
#[serde(default)]
|
||||
fmt: String,
|
||||
}
|
||||
|
||||
/// GET /api/qr 查询参数(保留向后兼容)
|
||||
#[derive(Deserialize)]
|
||||
struct QrParams {
|
||||
text: String,
|
||||
@@ -19,7 +39,6 @@ struct QrParams {
|
||||
margin: u8,
|
||||
#[serde(default = "default_size")]
|
||||
size: u8,
|
||||
/// fmt=svg 返回 SVG,否则返回 PNG
|
||||
#[serde(default)]
|
||||
fmt: String,
|
||||
}
|
||||
@@ -34,52 +53,66 @@ fn default_size() -> u8 {
|
||||
8
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Result<EcLevel, String> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"L" => Ok(EcLevel::L),
|
||||
"M" => Ok(EcLevel::M),
|
||||
"Q" => Ok(EcLevel::Q),
|
||||
"H" => Ok(EcLevel::H),
|
||||
_ => Err(format!("无效纠错级别: {},支持 L/M/Q/H", s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页 HTML(编译期嵌入)
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(include_str!("templates/index.html"))
|
||||
}
|
||||
|
||||
/// QR 码生成 API → PNG 或 SVG
|
||||
async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
|
||||
let level = match parse_level(¶ms.level) {
|
||||
/// 生成 QR 码的核心逻辑
|
||||
fn generate_qr_inner(text: &str, level_str: &str, margin: u8, size: u8, fmt: &str) -> impl IntoResponse {
|
||||
let level = match EcLevel::from_str(level_str) {
|
||||
Ok(l) => l,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
|
||||
};
|
||||
|
||||
// 验证参数范围
|
||||
if margin > 20 {
|
||||
return (StatusCode::BAD_REQUEST, "边距过大(最大 20)").into_response();
|
||||
}
|
||||
if size < 1 || size > 20 {
|
||||
return (StatusCode::BAD_REQUEST, "模块大小需在 1-20 之间").into_response();
|
||||
}
|
||||
|
||||
let config = QrConfig {
|
||||
level,
|
||||
version: VersionMode::Auto,
|
||||
margin: params.margin,
|
||||
margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = match QrCode::encode(¶ms.text, config) {
|
||||
let qr = match QrCode::encode(text, config) {
|
||||
Ok(q) => q,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_REQUEST, "QR 编码失败,请检查输入内容").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if params.fmt == "svg" {
|
||||
let svg = qr.to_svg(None);
|
||||
if fmt == "svg" {
|
||||
let svg = qr.to_svg(None, None, None);
|
||||
return ([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response();
|
||||
}
|
||||
let fmt = qr_core::render::image::OutputFormat::from_ext(¶ms.fmt)
|
||||
let out_fmt = qr_core::render::image::OutputFormat::from_ext(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(),
|
||||
match qr.to_image_bytes(size, None, Some(out_fmt), None, None) {
|
||||
Ok(b) => ([(header::CONTENT_TYPE, out_fmt.mime())], b).into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"图片生成失败,请稍后重试",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// QR 码生成 API → PNG 或 SVG(GET 查询参数,向后兼容)
|
||||
async fn generate_qr_get(Query(params): Query<QrParams>) -> impl IntoResponse {
|
||||
generate_qr_inner(¶ms.text, ¶ms.level, params.margin, params.size, ¶ms.fmt)
|
||||
}
|
||||
|
||||
/// QR 码生成 API → PNG 或 SVG(POST JSON 请求体,推荐)
|
||||
async fn generate_qr_post(axum::extract::Json(req): axum::extract::Json<QrRequest>) -> impl IntoResponse {
|
||||
generate_qr_inner(&req.text, &req.level, req.margin, req.size, &req.fmt)
|
||||
}
|
||||
|
||||
/// 解码结果 JSON 响应
|
||||
#[derive(Serialize)]
|
||||
struct DecodeResponse {
|
||||
@@ -109,25 +142,52 @@ async fn decode_qr(mut multipart: Multipart) -> impl IntoResponse {
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (StatusCode::BAD_REQUEST, e).into_response();
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"解码失败:图片中未检测到有效的 QR 码",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"文件上传失败,请重试",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(StatusCode::BAD_REQUEST, "未找到上传文件(字段名: file)").into_response()
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"未找到上传文件(表单字段名应为 'file')",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/qr", get(generate_qr))
|
||||
.route("/api/decode", post(decode_qr));
|
||||
.route("/api/qr", get(generate_qr_get).post(generate_qr_post))
|
||||
.route("/api/decode", post(decode_qr))
|
||||
// 安全层
|
||||
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10 MB
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
header::HeaderValue::from_static("nosniff"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
header::X_FRAME_OPTIONS,
|
||||
header::HeaderValue::from_static("DENY"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
header::REFERRER_POLICY,
|
||||
header::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
));
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
println!("QRGen Web → http://{}", addr);
|
||||
|
||||
@@ -167,7 +167,22 @@ function scheduleUpdate() {
|
||||
timer = setTimeout(doUpdate, 200);
|
||||
}
|
||||
|
||||
function doUpdate() {
|
||||
// 当前 QR 文本(避免在 URL 中泄露敏感数据)
|
||||
let currentQrText = '';
|
||||
// 缓存的预览 blob URL
|
||||
let previewBlobUrl = '';
|
||||
|
||||
async function fetchQrBlob(text, size) {
|
||||
const resp = await fetch('/api/qr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, level: cfg.level, margin: cfg.margin, size, fmt: 'png' })
|
||||
});
|
||||
if (!resp.ok) throw new Error('生成失败');
|
||||
return URL.createObjectURL(await resp.blob());
|
||||
}
|
||||
|
||||
async function doUpdate() {
|
||||
const text = buildQrText();
|
||||
if (!text) {
|
||||
document.getElementById('previewLoading').style.display = 'none';
|
||||
@@ -176,29 +191,32 @@ function doUpdate() {
|
||||
document.getElementById('btnCopy').disabled = true;
|
||||
document.getElementById('btnPng').disabled = true;
|
||||
document.getElementById('btnSvg').disabled = true;
|
||||
currentPngUrl = '';
|
||||
if (previewBlobUrl) { URL.revokeObjectURL(previewBlobUrl); previewBlobUrl = ''; }
|
||||
currentQrText = '';
|
||||
return;
|
||||
}
|
||||
currentQrText = text;
|
||||
|
||||
// 预览用 PNG(size=8, 清晰不拉伸),下载用用户选择的 size
|
||||
const previewUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=8`;
|
||||
currentPngUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=${cfg.size}`;
|
||||
|
||||
try {
|
||||
// 预览用 size=8(清晰不拉伸)
|
||||
previewBlobUrl = await fetchQrBlob(text, 8);
|
||||
const img = document.getElementById('previewImg');
|
||||
img.onload = () => {
|
||||
document.getElementById('previewLoading').style.display = 'none';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
img.onerror = () => {
|
||||
document.getElementById('previewLoading').style.display = 'none';
|
||||
document.getElementById('previewPlaceholder').style.display = 'block';
|
||||
document.getElementById('previewPlaceholder').textContent = '编码失败';
|
||||
};
|
||||
img.src = previewUrl;
|
||||
img.src = previewBlobUrl;
|
||||
document.getElementById('previewInfo').textContent = `${cfg.size}px/模块 · ${cfg.margin}px边距 · ${cfg.level}级`;
|
||||
document.getElementById('btnCopy').disabled = false;
|
||||
document.getElementById('btnPng').disabled = false;
|
||||
document.getElementById('btnSvg').disabled = false;
|
||||
} catch {
|
||||
document.getElementById('previewLoading').style.display = 'none';
|
||||
document.getElementById('previewPlaceholder').style.display = 'block';
|
||||
document.getElementById('previewPlaceholder').textContent = '编码失败';
|
||||
document.getElementById('previewInfo').textContent = '';
|
||||
currentQrText = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 事件绑定 ──
|
||||
@@ -239,23 +257,48 @@ document.querySelectorAll('.sidebar-l button').forEach(btn => {
|
||||
});
|
||||
|
||||
// ── 导出按钮 ──
|
||||
document.getElementById('btnPng').onclick = () => {
|
||||
if (currentPngUrl) { const a = document.createElement('a'); a.href = currentPngUrl; a.download = 'qrcode.png'; a.click(); }
|
||||
document.getElementById('btnPng').onclick = async () => {
|
||||
if (!currentQrText) return;
|
||||
try {
|
||||
const blob = await fetchQrBlob(currentQrText, cfg.size);
|
||||
const a = document.createElement('a'); a.href = blob; a.download = 'qrcode.png'; a.click();
|
||||
} catch(e) {
|
||||
const err = document.getElementById('errorBox');
|
||||
err.innerHTML = '<div class="error">导出 PNG 失败</div>';
|
||||
setTimeout(()=>err.innerHTML='',2000);
|
||||
}
|
||||
};
|
||||
document.getElementById('btnSvg').onclick = () => {
|
||||
const text = buildQrText();
|
||||
if (!text) return;
|
||||
const url = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`;
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'qrcode.svg'; a.click();
|
||||
document.getElementById('btnSvg').onclick = async () => {
|
||||
if (!currentQrText) return;
|
||||
try {
|
||||
const resp = await fetch('/api/qr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: currentQrText, level: cfg.level, margin: cfg.margin, size: 1, fmt: 'svg' })
|
||||
});
|
||||
if (!resp.ok) throw new Error('失败');
|
||||
const svg = await resp.text();
|
||||
const blob = new Blob([svg], {type:'image/svg+xml'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'qrcode.svg'; a.click();
|
||||
} catch(e) {
|
||||
const err = document.getElementById('errorBox');
|
||||
err.innerHTML = '<div class="error">导出 SVG 失败</div>';
|
||||
setTimeout(()=>err.innerHTML='',2000);
|
||||
}
|
||||
};
|
||||
document.getElementById('btnCopy').onclick = async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/qr?text=${encodeURIComponent(buildQrText())}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`);
|
||||
const resp = await fetch('/api/qr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: currentQrText, level: cfg.level, margin: cfg.margin, size: 1, fmt: 'svg' })
|
||||
});
|
||||
if (!resp.ok) throw new Error('失败');
|
||||
const svg = await resp.text();
|
||||
await navigator.clipboard.writeText(svg);
|
||||
} catch(e) {
|
||||
const err = document.getElementById('errorBox');
|
||||
err.innerHTML = `<div class="error">复制失败</div>`;
|
||||
err.innerHTML = '<div class="error">复制失败</div>';
|
||||
setTimeout(()=>err.innerHTML='',2000);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user