feat: 格式扩展 — 支持 BMP/JPEG/WebP 输出

- 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
This commit is contained in:
2026-06-19 21:34:21 +08:00
parent ef6b092eda
commit b41f6ee7df
6 changed files with 115 additions and 41 deletions
+20 -13
View File
@@ -55,6 +55,10 @@ struct Args {
#[arg(long)]
logo: Option<String>,
/// 输出图像格式 [png/bmp/jpeg/webp] [default: png]
#[arg(short = 'f', long, default_value = "png")]
format: String,
// ---- 编码模式参数 ----
/// 编码模式 [text/url/wifi/vcard/email/phone/sms/batch]
#[arg(long)]
@@ -199,24 +203,27 @@ fn main() -> anyhow::Result<()> {
.to_lowercase();
match ext.as_str() {
"png" => {
let bytes = qr.to_png_bytes(args.size, logo_bytes.as_deref())?;
fs::write(path, bytes)?;
println!(
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)",
path,
qr.version.0,
qr.size(),
qr.size(),
qr.level
);
}
"svg" => {
let svg = qr.to_svg(logo_bytes.as_deref());
fs::write(path, svg)?;
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
}
_ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext),
_ => {
let fmt = qr_core::render::image::OutputFormat::from_ext(&ext)
.or_else(|| qr_core::render::image::OutputFormat::from_ext(&args.format))
.unwrap_or(qr_core::render::image::OutputFormat::Png);
let bytes = qr.to_image_bytes(args.size, logo_bytes.as_deref(), Some(fmt))?;
fs::write(path, bytes)?;
println!(
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错, {})",
path,
qr.version.0,
qr.size(),
qr.size(),
qr.level,
fmt.extension()
);
}
}
}
None => {
+1 -1
View File
@@ -14,7 +14,7 @@ categories.workspace = true
rust-version.workspace = true
[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
+22 -7
View File
@@ -271,24 +271,39 @@ impl QrCode {
crate::render::ascii::render_ascii(self, invert)
}
/// 导出为 PNG 字节数据
/// 导出为图像字节数据(支持 PNG/BMP/JPEG/WebP
///
/// `module_size` 控制每个模块的像素大小(2~20),越大文件越大
/// `logo` 可选的 logo 图片字节,会在 QR 码中央叠加(建议搭配 H 级纠错)
/// `module_size` 控制每个模块的像素大小(2~20)。
/// `format` 输出格式,默认为 Png。
/// `logo` 可选的 logo 图片字节。
///
/// ```rust
/// use qr_core::qr::{QrCode, QrConfig};
///
/// let qr = QrCode::encode("PNG test", QrConfig::default()).unwrap();
/// let bytes = qr.to_png_bytes(4, None).unwrap();
/// std::fs::write("test.png", &bytes).unwrap();
/// let qr = QrCode::encode("test", QrConfig::default()).unwrap();
/// let bytes = qr.to_image_bytes(4, 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> {
crate::render::image::render_image(
self,
module_size,
format.unwrap_or(crate::render::image::OutputFormat::Png),
logo,
)
}
/// 导出为 PNG 字节数据(便捷方法,兼容旧 API)
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)
self.to_image_bytes(module_size, logo, None)
}
}
@@ -1,6 +1,62 @@
//! 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,
@@ -24,32 +80,27 @@ fn fill_module(
}
}
/// 在 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(()); // 太小,跳过
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 码到图像字节(支持 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;
@@ -72,7 +123,6 @@ pub fn render_png(
} else {
false
};
fill_module(
&mut img,
x,
@@ -85,13 +135,14 @@ pub fn render_png(
}
}
// 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)?;
img.write_to(
&mut std::io::Cursor::new(&mut buf),
format.to_image_format(),
)?;
Ok(buf)
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub mod ascii;
pub mod png;
pub mod image;
pub mod svg;
+7 -6
View File
@@ -70,12 +70,13 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
if params.fmt == "svg" {
let svg = qr.to_svg(None);
([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response()
} else {
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(),
}
return ([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response();
}
let fmt = qr_core::render::image::OutputFormat::from_ext(&params.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(),
}
}