From 38be82973e985485e628740c33b329bf750d6aa6 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:12:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Logo=20=E5=B5=8C=E5=85=A5=20=E2=80=94?= =?UTF-8?q?=20QR=20=E7=A0=81=E4=B8=AD=E5=A4=AE=E5=8F=A0=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PNG 渲染:RgbaImage 上使用 imageops::overlay 叠加 logo - SVG 渲染:base64 编码 logo 嵌入 标签 - QrCode::to_png_bytes / to_svg 新增 Option 参数 - Logo 默认占 QR 区域 25%,建议配合 H 级纠错使用 --- cli/src/main.rs | 4 +-- core/src/qr.rs | 26 ++++++++++------ core/src/render/png.rs | 56 ++++++++++++++++++++++++++-------- core/src/render/svg.rs | 42 +++++++++++++++++++++++-- core/tests/integration_test.rs | 8 ++--- examples/basic_qr.rs | 4 +-- examples/custom_config.rs | 2 +- examples/high_ecc.rs | 2 +- gui/src/lib.rs | 4 +-- web/src/main.rs | 4 +-- 10 files changed, 112 insertions(+), 40 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index bf36dac..a1453df 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -112,7 +112,7 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { match ext.as_str() { "png" => { - let bytes = qr.to_png_bytes(args.size)?; + let bytes = qr.to_png_bytes(args.size, None)?; std::fs::write(path, bytes)?; println!( "已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)", @@ -124,7 +124,7 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> { ); } "svg" => { - let svg = qr.to_svg(); + let svg = qr.to_svg(None); std::fs::write(path, svg)?; println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0); } diff --git a/core/src/qr.rs b/core/src/qr.rs index 9b2e875..f8d9c0b 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -93,11 +93,11 @@ impl Default for QrConfig { /// println!("版本: {}, 尺寸: {}×{}", qr.version.0, qr.size(), qr.size()); /// /// // 导出为 SVG -/// let svg = qr.to_svg(); +/// let svg = qr.to_svg(None); /// assert!(svg.starts_with(" 100); /// /// // 终端 ASCII 输出 @@ -249,17 +249,18 @@ impl QrCode { /// 导出为 SVG 字符串 /// - /// SVG 内含 `viewBox`、深色模块用 `#000` 填充。 + /// SVG 内含 `viewBox`、使用 QrCode 的前/背景色。 + /// `logo` 可选的 logo 图片字节,会以 base64 嵌入 SVG 中央。 /// /// ```rust /// use qr_core::qr::{QrCode, QrConfig}; /// /// let qr = QrCode::encode("test", QrConfig::default()).unwrap(); - /// let svg = qr.to_svg(); + /// let svg = qr.to_svg(None); /// assert!(svg.starts_with(" String { - crate::render::svg::render_svg(self) + pub fn to_svg(&self, logo: Option<&[u8]>) -> String { + crate::render::svg::render_svg(self, logo) } /// 导出为终端 ASCII 文本 @@ -273,16 +274,21 @@ impl QrCode { /// 导出为 PNG 字节数据 /// /// `module_size` 控制每个模块的像素大小(2~20),越大文件越大。 + /// `logo` 可选的 logo 图片字节,会在 QR 码中央叠加(建议搭配 H 级纠错) /// /// ```rust /// use qr_core::qr::{QrCode, QrConfig}; /// /// let qr = QrCode::encode("PNG test", QrConfig::default()).unwrap(); - /// let bytes = qr.to_png_bytes(4).unwrap(); + /// let bytes = qr.to_png_bytes(4, None).unwrap(); /// std::fs::write("test.png", &bytes).unwrap(); /// ``` - pub fn to_png_bytes(&self, module_size: u8) -> Result, image::ImageError> { - crate::render::png::render_png(self, module_size) + pub fn to_png_bytes( + &self, + module_size: u8, + logo: Option<&[u8]>, + ) -> Result, image::ImageError> { + crate::render::png::render_png(self, module_size, logo) } } @@ -383,7 +389,7 @@ mod tests { 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(); + let svg = qr.to_svg(None); assert!(svg.contains("#FF0000")); assert!(svg.contains("#0000FF")); } diff --git a/core/src/render/png.rs b/core/src/render/png.rs index d89d736..baf1dea 100644 --- a/core/src/render/png.rs +++ b/core/src/render/png.rs @@ -1,9 +1,8 @@ use crate::qr::QrCode; -use image::{ImageBuffer, Rgba}; +use image::{imageops, ImageBuffer, Rgba, RgbaImage}; -/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块) fn fill_module( - img: &mut ImageBuffer, Vec>, + img: &mut RgbaImage, x: u32, y: u32, module_size: u32, @@ -25,7 +24,40 @@ fn fill_module( } } -pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageError> { +/// 在 QR 码 PNG 缓冲区中央叠加 logo +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}"))?; + let logo = logo.to_rgba8(); + + let img_w = img.width(); + let img_h = img.height(); + + // Logo 边长 = min(图像边长 * pct, 实际 QR 区域 * pct) + let logo_size = (img_w.min(img_h) as f32 * logo_size_pct) as u32; + if logo_size < 4 { + return Ok(()); // 太小,跳过 + } + + let resized = imageops::resize( + &logo, + logo_size, + logo_size, + imageops::FilterType::Lanczos3, + ); + + let x = (img_w - logo_size) / 2; + let y = (img_h - logo_size) / 2; + imageops::overlay(img, &resized, x as i64, y as i64); + + Ok(()) +} + +pub fn render_png( + qr: &QrCode, + module_size: u8, + logo: Option<&[u8]>, +) -> Result, image::ImageError> { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; let total_size = matrix_size + 2 * margin; @@ -47,18 +79,16 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageE 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, &qr.fg_color, &qr.bg_color); } } + // Logo 叠加 + if let Some(logo_data) = logo { + // 忽略 logo 叠加错误(logo 有损不影响 QR 主体) + let _ = overlay_logo(&mut img, logo_data, 0.25); + } + let mut buf = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?; Ok(buf) diff --git a/core/src/render/svg.rs b/core/src/render/svg.rs index b8216a6..9288572 100644 --- a/core/src/render/svg.rs +++ b/core/src/render/svg.rs @@ -1,6 +1,6 @@ use crate::qr::QrCode; -pub fn render_svg(qr: &QrCode) -> String { +pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; let total = matrix_size + 2 * margin; @@ -20,9 +20,9 @@ pub fn render_svg(qr: &QrCode) -> String { .flat_map(|row| row.iter()) .filter(|&&m| m) .count(); - let mut svg = String::with_capacity(200 + dark_count * 50); + let mut svg = String::with_capacity(300 + dark_count * 50); svg.push_str(&format!( - r#""# + r#""# )); svg.push_str(&format!( r#""# @@ -40,6 +40,42 @@ pub fn render_svg(qr: &QrCode) -> String { } } + // Logo 嵌入:base64 PNG data URL + if let Some(logo_bytes) = logo { + let b64 = base64_encode(logo_bytes); + let logo_size = total as f32 * 0.25; + let logo_x = (total as f32 - logo_size) / 2.0; + let logo_y = (total as f32 - logo_size) / 2.0; + svg.push_str(&format!( + r#""# + )); + } + svg.push_str(""); svg } + +/// 简易 base64 编码(无外部依赖) +fn base64_encode(data: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let triple = (b0 << 16) | (b1 << 8) | b2; + result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char); + result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { + result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char); + } else { + result.push('='); + } + if chunk.len() > 2 { + result.push(CHARS[(triple & 0x3F) as usize] as char); + } else { + result.push('='); + } + } + result +} diff --git a/core/tests/integration_test.rs b/core/tests/integration_test.rs index 0146c33..ea167a7 100644 --- a/core/tests/integration_test.rs +++ b/core/tests/integration_test.rs @@ -193,7 +193,7 @@ fn test_format_info_written() { #[test] fn test_svg_valid_structure() { let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap(); - let svg = qr.to_svg(); + let svg = qr.to_svg(None); // SVG 应有正确的结构 assert!(svg.starts_with("")); assert!(svg.contains("fill=\"black\"")); @@ -332,7 +332,7 @@ fn test_ascii_output() { #[test] fn test_png_output() { let qr = QrCode::encode("TEST", QrConfig::default()).unwrap(); - let png = qr.to_png_bytes(4).unwrap(); + let png = qr.to_png_bytes(4, None).unwrap(); assert!(!png.is_empty()); // PNG 文件应以 8 字节魔术签名开头 assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]); @@ -356,7 +356,7 @@ fn test_margin_is_included_in_dimensions() { let qr = QrCode::encode("MARGIN TEST", config).unwrap(); // SVG 的总宽度应该包含 margin - let svg = qr.to_svg(); + let svg = qr.to_svg(None); let matrix_size = qr.size() as u32; let expected_total = matrix_size + 2 * 2u32; assert!(svg.contains(&format!("width=\"{}\"", expected_total))); diff --git a/examples/basic_qr.rs b/examples/basic_qr.rs index 5023984..7a1e423 100644 --- a/examples/basic_qr.rs +++ b/examples/basic_qr.rs @@ -19,11 +19,11 @@ fn main() -> Result<(), Box> { println!("{}", qr.to_ascii(false)); // 导出 PNG - qr.to_png_bytes(8)?; + qr.to_png_bytes(8, None)?; println!("\nPNG 生成成功"); // 导出 SVG - let svg = qr.to_svg(); + let svg = qr.to_svg(None); println!("SVG 长度: {} 字节", svg.len()); Ok(()) diff --git a/examples/custom_config.rs b/examples/custom_config.rs index fa62caf..44fbf07 100644 --- a/examples/custom_config.rs +++ b/examples/custom_config.rs @@ -17,7 +17,7 @@ fn main() -> Result<(), Box> { assert_eq!(qr.version.0, 10); // 导出大尺寸 PNG(每个模块 8 像素) - let png = qr.to_png_bytes(8)?; + let png = qr.to_png_bytes(8, None)?; println!("版本 10 QR 码 PNG: {} 字节", png.len()); // 反转色终端输出(白底黑码 → 黑底白码) diff --git a/examples/high_ecc.rs b/examples/high_ecc.rs index a3c80ff..041f483 100644 --- a/examples/high_ecc.rs +++ b/examples/high_ecc.rs @@ -21,7 +21,7 @@ fn main() -> Result<(), Box> { qr.size() ); - let svg = qr.to_svg(); + let svg = qr.to_svg(None); println!("SVG 生成成功: {} 字节", svg.len()); Ok(()) diff --git a/gui/src/lib.rs b/gui/src/lib.rs index fb69b82..cc65cf3 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -50,7 +50,7 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result Resul let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?; - qr.to_png_bytes(module_size) + qr.to_png_bytes(module_size, None) .map_err(|e| format!("PNG 导出失败: {}", e)) } diff --git a/web/src/main.rs b/web/src/main.rs index eee19bc..e7ef166 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -69,10 +69,10 @@ async fn generate_qr(Query(params): Query) -> impl IntoResponse { }; if params.fmt == "svg" { - let svg = qr.to_svg(); + let svg = qr.to_svg(None); ([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response() } else { - match qr.to_png_bytes(params.size) { + match qr.to_png_bytes(params.size, None) { Ok(b) => ([(header::CONTENT_TYPE, "image/png")], b).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }