diff --git a/cli/src/main.rs b/cli/src/main.rs index 0c70f03..e362b26 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -55,6 +55,10 @@ struct Args { #[arg(long)] logo: Option, + /// 输出图像格式 [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 => { diff --git a/core/Cargo.toml b/core/Cargo.toml index 3063188..5dfae9d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -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] diff --git a/core/src/qr.rs b/core/src/qr.rs index f8d9c0b..c05a697 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -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, + ) -> Result, 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, image::ImageError> { - crate::render::png::render_png(self, module_size, logo) + self.to_image_bytes(module_size, logo, None) } } diff --git a/core/src/render/png.rs b/core/src/render/image.rs similarity index 57% rename from core/src/render/png.rs rename to core/src/render/image.rs index fed48f6..2a65968 100644 --- a/core/src/render/png.rs +++ b/core/src/render/image.rs @@ -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 { + 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, 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) } diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 63b6daf..f77b46a 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -1,3 +1,3 @@ pub mod ascii; -pub mod png; +pub mod image; pub mod svg; diff --git a/web/src/main.rs b/web/src/main.rs index e7ef166..80ecdac 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -70,12 +70,13 @@ async fn generate_qr(Query(params): Query) -> 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(¶ms.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(), } }