b41f6ee7df
- png.rs 重命名为 image.rs,新增 OutputFormat 枚举 - QrCode::to_image_bytes 支持 PNG/BMP/JPEG/WebP - CLI 新增 -f/--format 参数(png/bmp/jpeg/webp) - Web API fmt 参数扩展至全部 4 种图像格式 - core/Cargo.toml: image crate 新增 bmp feature
149 lines
4.0 KiB
Rust
149 lines
4.0 KiB
Rust
//! QR 码图像渲染(支持 PNG/BMP/JPEG/WebP)
|
||
//!
|
||
//! 使用 `image` crate 将 QR 模块矩阵渲染为像素缓冲区,可选叠加 Logo。
|
||
|
||
use crate::qr::QrCode;
|
||
use image::{imageops, ImageBuffer, Rgba, RgbaImage};
|
||
|
||
/// 输出图像格式
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum OutputFormat {
|
||
Png,
|
||
Bmp,
|
||
Jpeg,
|
||
WebP,
|
||
}
|
||
|
||
impl OutputFormat {
|
||
/// 转为 `image` crate 的格式枚举
|
||
fn to_image_format(self) -> image::ImageFormat {
|
||
match self {
|
||
Self::Png => image::ImageFormat::Png,
|
||
Self::Bmp => image::ImageFormat::Bmp,
|
||
Self::Jpeg => image::ImageFormat::Jpeg,
|
||
Self::WebP => image::ImageFormat::WebP,
|
||
}
|
||
}
|
||
|
||
/// 文件扩展名(不含点)
|
||
pub fn extension(self) -> &'static str {
|
||
match self {
|
||
Self::Png => "png",
|
||
Self::Bmp => "bmp",
|
||
Self::Jpeg => "jpeg",
|
||
Self::WebP => "webp",
|
||
}
|
||
}
|
||
|
||
/// MIME 类型
|
||
pub fn mime(self) -> &'static str {
|
||
match self {
|
||
Self::Png => "image/png",
|
||
Self::Bmp => "image/bmp",
|
||
Self::Jpeg => "image/jpeg",
|
||
Self::WebP => "image/webp",
|
||
}
|
||
}
|
||
|
||
/// 从扩展名解析
|
||
pub fn from_ext(ext: &str) -> Option<Self> {
|
||
match ext.to_lowercase().as_str() {
|
||
"png" => Some(Self::Png),
|
||
"bmp" => Some(Self::Bmp),
|
||
"jpeg" | "jpg" => Some(Self::Jpeg),
|
||
"webp" => Some(Self::WebP),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fill_module(
|
||
img: &mut RgbaImage,
|
||
x: u32,
|
||
y: u32,
|
||
module_size: u32,
|
||
is_dark: bool,
|
||
fg: &[u8; 3],
|
||
bg: &[u8; 3],
|
||
) {
|
||
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, color);
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
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(())
|
||
}
|
||
|
||
/// 渲染 QR 码到图像字节(支持 PNG/BMP/JPEG/WebP)
|
||
pub fn render_image(
|
||
qr: &QrCode,
|
||
module_size: u8,
|
||
format: OutputFormat,
|
||
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;
|
||
let img_size = total_size * module_size as u32;
|
||
|
||
let mut img = ImageBuffer::new(img_size, img_size);
|
||
|
||
for y in 0..total_size {
|
||
for x in 0..total_size {
|
||
let is_dark = if x >= margin
|
||
&& x < margin + matrix_size
|
||
&& y >= margin
|
||
&& y < margin + matrix_size
|
||
{
|
||
let mx = (x - margin) as usize;
|
||
let my = (y - margin) as usize;
|
||
qr.modules()[my][mx]
|
||
} else {
|
||
false
|
||
};
|
||
fill_module(
|
||
&mut img,
|
||
x,
|
||
y,
|
||
module_size as u32,
|
||
is_dark,
|
||
&qr.fg_color,
|
||
&qr.bg_color,
|
||
);
|
||
}
|
||
}
|
||
|
||
if let Some(logo_data) = logo {
|
||
let _ = overlay_logo(&mut img, logo_data, 0.25);
|
||
}
|
||
|
||
let mut buf = Vec::new();
|
||
img.write_to(
|
||
&mut std::io::Cursor::new(&mut buf),
|
||
format.to_image_format(),
|
||
)?;
|
||
Ok(buf)
|
||
}
|