From 23ccb37b5217f46a41d2725236564f9f9ab25818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Fri, 19 Jun 2026 21:10:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BD=A9=E8=89=B2=20QR=20=E7=A0=81=20?= =?UTF-8?q?=E2=80=94=20=E8=87=AA=E5=AE=9A=E4=B9=89=E5=89=8D=E6=99=AF?= =?UTF-8?q?=E8=89=B2/=E8=83=8C=E6=99=AF=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QrConfig 新增 fg_color/bg_color 字段(CSS 十六进制格式) - QrCode 存储解析后的 [u8;3] RGB - PNG 渲染 Luma→Rgba,支持 RGBA 颜色 - SVG 渲染使用 QrCode 颜色字段 - CLI 新增 --fg/--bg 参数 - 新增 parse_hex_color 支持 #RRGGBB 和 #RGB - 新增 2 个颜色测试(74 tests total) --- cli/src/main.rs | 10 ++++++ core/src/qr.rs | 75 +++++++++++++++++++++++++++++++++++++++++- core/src/render/png.rs | 27 +++++++++++---- core/src/render/svg.rs | 18 ++++++---- gui/src/lib.rs | 2 ++ web/src/main.rs | 1 + 6 files changed, 119 insertions(+), 14 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index cc01d40..bf36dac 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -39,6 +39,14 @@ struct Args { /// 反色(黑底白码) #[arg(long)] invert: bool, + + /// 前景色 "#RRGGBB" [default: "#000000"] + #[arg(long)] + fg: Option, + + /// 背景色 "#RRGGBB" [default: "#FFFFFF"] + #[arg(long)] + bg: Option, } fn main() -> anyhow::Result<()> { @@ -80,6 +88,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { level, version, margin: args.margin, + fg_color: args.fg.clone(), + bg_color: args.bg.clone(), }; let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?; diff --git a/core/src/qr.rs b/core/src/qr.rs index 845bb77..9b2e875 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -63,15 +63,21 @@ pub struct QrConfig { pub version: VersionMode, /// 静区边距(模块数),默认 4 pub margin: u8, + /// 前景色(CSS 格式 "#RRGGBB"),默认 "#000000" + pub fg_color: Option, + /// 背景色(CSS 格式 "#RRGGBB"),默认 "#FFFFFF" + pub bg_color: Option, } 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, } } } @@ -108,6 +114,10 @@ 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 { @@ -203,12 +213,17 @@ 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, }) } @@ -271,6 +286,39 @@ impl QrCode { } } +/// 解析 CSS 十六进制颜色 → [R, G, B] +/// +/// 支持格式: "#RGB", "#RRGGBB" +/// 无效格式返回 Err +fn parse_hex_color(s: &str) -> Result<[u8; 3], String> { + let s = s.trim(); + if !s.starts_with('#') { + return Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)); + } + 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))?; + 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))?; + Ok([r, g, b]) + } + _ => Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -314,4 +362,29 @@ mod tests { assert!(qr.size() >= 21); } } + + #[test] + fn test_parse_hex_color() { + assert_eq!(parse_hex_color("#000000").unwrap(), [0, 0, 0]); + assert_eq!(parse_hex_color("#FFFFFF").unwrap(), [255, 255, 255]); + assert_eq!(parse_hex_color("#FF5733").unwrap(), [255, 87, 51]); + assert_eq!(parse_hex_color("#F53").unwrap(), [255, 85, 51]); + assert!(parse_hex_color("invalid").is_err()); + assert!(parse_hex_color("#GGGGGG").is_err()); + } + + #[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(); + assert!(svg.contains("#FF0000")); + assert!(svg.contains("#0000FF")); + } } diff --git a/core/src/render/png.rs b/core/src/render/png.rs index 02112bb..d89d736 100644 --- a/core/src/render/png.rs +++ b/core/src/render/png.rs @@ -1,20 +1,26 @@ use crate::qr::QrCode; -use image::{ImageBuffer, Luma}; +use image::{ImageBuffer, Rgba}; /// 将单个模块填充到图像缓冲区(module_size × module_size 像素块) fn fill_module( - img: &mut ImageBuffer, Vec>, + img: &mut ImageBuffer, Vec>, x: u32, y: u32, module_size: u32, is_dark: bool, + fg: &[u8; 3], + bg: &[u8; 3], ) { - let px_val = if is_dark { 0u8 } else { 255u8 }; + let color = if is_dark { + Rgba([fg[0], fg[1], fg[2], 255]) + } else { + Rgba([bg[0], bg[1], bg[2], 255]) + }; let x0 = x * module_size; let y0 = y * module_size; for dy in 0..module_size { for dx in 0..module_size { - img.put_pixel(x0 + dx, y0 + dy, Luma([px_val])); + img.put_pixel(x0 + dx, y0 + dy, color); } } } @@ -29,7 +35,6 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageE for y in 0..total_size { for x in 0..total_size { - // 直接比较坐标与 margin 边界,避免 saturating_sub 在边界处回绕到 0 let is_dark = if x >= margin && x < margin + matrix_size && y >= margin @@ -39,10 +44,18 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageE let my = (y - margin) as usize; qr.modules()[my][mx] } else { - false // 白边 (quiet zone) + false }; - fill_module(&mut img, x, y, module_size as u32, is_dark); + fill_module( + &mut img, + x, + y, + module_size as u32, + is_dark, + &qr.fg_color, + &qr.bg_color, + ); } } diff --git a/core/src/render/svg.rs b/core/src/render/svg.rs index 43b4b27..b8216a6 100644 --- a/core/src/render/svg.rs +++ b/core/src/render/svg.rs @@ -5,7 +5,15 @@ pub fn render_svg(qr: &QrCode) -> String { let margin = qr.margin as u32; let total = matrix_size + 2 * margin; - // 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节 + 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 dark_count = qr .modules() .iter() @@ -14,19 +22,17 @@ pub fn render_svg(qr: &QrCode) -> String { .count(); let mut svg = String::with_capacity(200 + dark_count * 50); svg.push_str(&format!( - r#""#, - total, total, total, total + r#""# )); svg.push_str(&format!( - r#""#, - total, total + r#""# )); 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#""#, + r#""#, x + margin, y + margin )); diff --git a/gui/src/lib.rs b/gui/src/lib.rs index c6ca74e..fb69b82 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -45,6 +45,7 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result Resul level: ec_level, version: VersionMode::Auto, margin, + ..Default::default() }; let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?; diff --git a/web/src/main.rs b/web/src/main.rs index e225182..eee19bc 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -60,6 +60,7 @@ async fn generate_qr(Query(params): Query) -> impl IntoResponse { level, version: VersionMode::Auto, margin: params.margin, + ..Default::default() }; let qr = match QrCode::encode(¶ms.text, config) {