feat: Logo 嵌入 — QR 码中央叠加自定义图片

- PNG 渲染:RgbaImage 上使用 imageops::overlay 叠加 logo
- SVG 渲染:base64 编码 logo 嵌入 <image> 标签
- QrCode::to_png_bytes / to_svg 新增 Option<logo_bytes> 参数
- Logo 默认占 QR 区域 25%,建议配合 H 级纠错使用
This commit is contained in:
2026-06-19 21:12:44 +08:00
parent 23ccb37b52
commit 38be82973e
10 changed files with 112 additions and 40 deletions
+16 -10
View File
@@ -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("<svg"));
///
/// // 导出为 PNG 字节
/// let png = qr.to_png_bytes(4).unwrap();
/// let png = qr.to_png_bytes(4, None).unwrap();
/// assert!(png.len() > 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("<svg"));
/// ```
pub fn to_svg(&self) -> 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<Vec<u8>, image::ImageError> {
crate::render::png::render_png(self, module_size)
pub fn to_png_bytes(
&self,
module_size: u8,
logo: Option<&[u8]>,
) -> Result<Vec<u8>, 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"));
}
+43 -13
View File
@@ -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<Rgba<u8>, Vec<u8>>,
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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)
+39 -3
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="{total}" viewBox="0 0 {total} {total}">"#
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}"/>"#
@@ -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#"<image x="{logo_x}" y="{logo_y}" width="{logo_size}" height="{logo_size}" xlink:href="data:image/png;base64,{b64}"/>"#
));
}
svg.push_str("</svg>");
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
}
+4 -4
View File
@@ -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("<svg"), "SVG 应以 <svg 开头");
assert!(svg.contains("rect"), "SVG 应包含 rect 元素");
@@ -313,7 +313,7 @@ fn test_empty_input_fails() {
#[test]
fn test_svg_output() {
let qr = QrCode::encode("TEST", QrConfig::default()).unwrap();
let svg = qr.to_svg();
let svg = qr.to_svg(None);
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
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)));