feat: 实现二维码高级渲染功能,支持自定义颜色、形状和 Logo 嵌入
This commit is contained in:
67
src-tauri/Cargo.lock
generated
67
src-tauri/Cargo.lock
generated
@@ -860,6 +860,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -3574,6 +3576,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.52"
|
||||
@@ -4224,6 +4250,7 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-opener",
|
||||
"windows 0.58.0",
|
||||
@@ -4310,6 +4337,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -21,6 +21,7 @@ tauri-build = { version = "2.4", features = [] }
|
||||
tauri = { version = "2.4", features = [] }
|
||||
tauri-plugin-opener = "2.5"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
sysinfo = "0.30"
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-is-visible",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
// 预热取色器窗口:避免第一次取色出现“白屏闪一下”
|
||||
// 窗口会以 hidden 状态创建,不会影响用户体验
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::utils::color_conversion;
|
||||
///
|
||||
/// 包含颜色的完整信息,支持多种颜色格式和屏幕坐标
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorInfo {
|
||||
/// 十六进制颜色值(格式:#RRGGBB)
|
||||
pub hex: String,
|
||||
@@ -62,6 +63,7 @@ impl ColorInfo {
|
||||
///
|
||||
/// 表示 RGB 颜色模式的颜色值
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RgbInfo {
|
||||
/// 红色分量 (0-255)
|
||||
pub r: u8,
|
||||
@@ -75,6 +77,7 @@ pub struct RgbInfo {
|
||||
///
|
||||
/// 表示 HSL 颜色模式的颜色值
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HslInfo {
|
||||
/// 色相 (0-360)
|
||||
///
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// JSON 格式化配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsonFormatConfig {
|
||||
/// 缩进空格数(默认 2)
|
||||
#[serde(default = "default_indent")]
|
||||
@@ -54,6 +55,7 @@ impl Default for JsonFormatConfig {
|
||||
|
||||
/// JSON 格式化结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsonFormatResult {
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
@@ -70,6 +72,7 @@ pub struct JsonFormatResult {
|
||||
|
||||
/// JSON 验证结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsonValidateResult {
|
||||
/// 是否有效的 JSON
|
||||
pub is_valid: bool,
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
///
|
||||
/// 定义生成二维码所需的参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QrConfig {
|
||||
/// 二维码内容
|
||||
pub content: String,
|
||||
@@ -15,12 +16,17 @@ pub struct QrConfig {
|
||||
pub margin: u32,
|
||||
/// 容错级别: "L", "M", "Q", "H"
|
||||
pub error_correction: String,
|
||||
/// 样式配置
|
||||
pub style: Option<QrStyle>,
|
||||
/// Logo 配置
|
||||
pub logo: Option<LogoConfig>,
|
||||
}
|
||||
|
||||
/// 二维码样式(阶段 2 使用)
|
||||
/// 二维码样式
|
||||
///
|
||||
/// 定义二维码的视觉样式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QrStyle {
|
||||
/// 点形状: "square", "circle", "rounded"
|
||||
pub dot_shape: String,
|
||||
@@ -36,10 +42,24 @@ pub struct QrStyle {
|
||||
pub gradient_colors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Logo 配置(阶段 2 使用)
|
||||
impl Default for QrStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dot_shape: "square".to_string(),
|
||||
eye_shape: "square".to_string(),
|
||||
foreground_color: "#000000".to_string(),
|
||||
background_color: "#FFFFFF".to_string(),
|
||||
is_gradient: false,
|
||||
gradient_colors: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logo 配置
|
||||
///
|
||||
/// 定义 Logo 的位置和样式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoConfig {
|
||||
/// Logo 文件路径
|
||||
pub path: String,
|
||||
@@ -55,6 +75,7 @@ pub struct LogoConfig {
|
||||
///
|
||||
/// 包含生成的二维码图片数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QrResult {
|
||||
/// Base64 编码的图片数据
|
||||
pub data: String,
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 系统信息(完整版)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemInfo {
|
||||
/// 操作系统信息
|
||||
pub os: OsInfo,
|
||||
@@ -29,6 +30,7 @@ pub struct SystemInfo {
|
||||
|
||||
/// 操作系统信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OsInfo {
|
||||
/// 操作系统名称
|
||||
pub name: String,
|
||||
@@ -46,6 +48,7 @@ pub struct OsInfo {
|
||||
|
||||
/// 硬件信息(主板、BIOS)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HardwareInfo {
|
||||
/// 制造商
|
||||
pub manufacturer: String,
|
||||
@@ -59,6 +62,7 @@ pub struct HardwareInfo {
|
||||
|
||||
/// CPU 信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CpuInfo {
|
||||
/// CPU 型号
|
||||
pub model: String,
|
||||
@@ -74,6 +78,7 @@ pub struct CpuInfo {
|
||||
|
||||
/// 内存信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MemoryInfo {
|
||||
/// 总内存 (GB)
|
||||
pub total_gb: f64,
|
||||
@@ -87,6 +92,7 @@ pub struct MemoryInfo {
|
||||
|
||||
/// GPU 信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GpuInfo {
|
||||
/// GPU 名称
|
||||
pub name: String,
|
||||
@@ -98,6 +104,7 @@ pub struct GpuInfo {
|
||||
|
||||
/// 磁盘信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiskInfo {
|
||||
/// 盘符 (如 "C:")
|
||||
pub drive_letter: String,
|
||||
@@ -117,6 +124,7 @@ pub struct DiskInfo {
|
||||
|
||||
/// 计算机信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComputerInfo {
|
||||
/// 计算机名称
|
||||
pub name: String,
|
||||
@@ -134,6 +142,7 @@ pub struct ComputerInfo {
|
||||
|
||||
/// 显示器信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisplayInfo {
|
||||
/// 屏幕数量
|
||||
pub monitor_count: u32,
|
||||
@@ -145,6 +154,7 @@ pub struct DisplayInfo {
|
||||
|
||||
/// 网络信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkInfo {
|
||||
/// 网络接口列表
|
||||
pub interfaces: Vec<InterfaceInfo>,
|
||||
@@ -156,6 +166,7 @@ pub struct NetworkInfo {
|
||||
|
||||
/// 网络接口信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InterfaceInfo {
|
||||
/// 接口名称
|
||||
pub name: String,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::qrcode::{QrConfig, QrResult};
|
||||
use crate::utils::qrcode_renderer::{image_to_base64, render_basic_qr};
|
||||
use crate::utils::qrcode_renderer::{image_to_base64, render_qr};
|
||||
|
||||
/// 二维码生成服务
|
||||
pub struct QrCodeService;
|
||||
@@ -39,8 +39,17 @@ impl QrCodeService {
|
||||
return Err(AppError::InvalidData("边距不能超过 50".to_string()));
|
||||
}
|
||||
|
||||
// 验证 Logo 缩放比例
|
||||
if let Some(logo) = &config.logo {
|
||||
if logo.scale < 0.05 || logo.scale > 0.3 {
|
||||
return Err(AppError::InvalidData(
|
||||
"Logo 缩放比例必须在 0.05 到 0.3 之间".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染二维码
|
||||
let img = render_basic_qr(config)?;
|
||||
let img = render_qr(config)?;
|
||||
|
||||
// 转换为 Base64
|
||||
let base64_data = image_to_base64(&img)?;
|
||||
@@ -76,8 +85,22 @@ impl QrCodeService {
|
||||
));
|
||||
}
|
||||
|
||||
// 验证边距
|
||||
if config.margin > 50 {
|
||||
return Err(AppError::InvalidData("边距不能超过 50".to_string()));
|
||||
}
|
||||
|
||||
// 验证 Logo 缩放比例
|
||||
if let Some(logo) = &config.logo {
|
||||
if logo.scale < 0.05 || logo.scale > 0.3 {
|
||||
return Err(AppError::InvalidData(
|
||||
"Logo 缩放比例必须在 0.05 到 0.3 之间".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染二维码
|
||||
let img = render_basic_qr(config)?;
|
||||
let img = render_qr(config)?;
|
||||
|
||||
// 保存到文件
|
||||
img.save(output_path)
|
||||
@@ -90,6 +113,7 @@ impl QrCodeService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::qrcode::{QrStyle};
|
||||
|
||||
#[test]
|
||||
fn test_generate_preview() {
|
||||
@@ -98,6 +122,8 @@ mod tests {
|
||||
size: 512,
|
||||
margin: 4,
|
||||
error_correction: "M".to_string(),
|
||||
style: None,
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = QrCodeService::generate_preview(&config);
|
||||
@@ -108,6 +134,30 @@ mod tests {
|
||||
assert_eq!(qr_result.format, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_preview_with_style() {
|
||||
let style = QrStyle {
|
||||
dot_shape: "circle".to_string(),
|
||||
eye_shape: "square".to_string(),
|
||||
foreground_color: "#FF0000".to_string(),
|
||||
background_color: "#FFFFFF".to_string(),
|
||||
is_gradient: false,
|
||||
gradient_colors: None,
|
||||
};
|
||||
|
||||
let config = QrConfig {
|
||||
content: "https://example.com".to_string(),
|
||||
size: 512,
|
||||
margin: 4,
|
||||
error_correction: "M".to_string(),
|
||||
style: Some(style),
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = QrCodeService::generate_preview(&config);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_preview_invalid_size() {
|
||||
let config = QrConfig {
|
||||
@@ -115,6 +165,8 @@ mod tests {
|
||||
size: 50, // 太小
|
||||
margin: 4,
|
||||
error_correction: "M".to_string(),
|
||||
style: None,
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = QrCodeService::generate_preview(&config);
|
||||
@@ -128,6 +180,8 @@ mod tests {
|
||||
size: 512,
|
||||
margin: 100, // 太大
|
||||
error_correction: "M".to_string(),
|
||||
style: None,
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = QrCodeService::generate_preview(&config);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
//! 二维码渲染工具函数
|
||||
//!
|
||||
//! 提供二维码矩阵到图像的渲染功能
|
||||
//! 提供二维码矩阵到图像的渲染功能,支持颜色、形状和 Logo
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::qrcode::QrConfig;
|
||||
use crate::models::qrcode::{QrConfig, QrStyle};
|
||||
use base64::Engine;
|
||||
use image::Luma;
|
||||
use image::Rgba;
|
||||
use image::RgbaImage;
|
||||
use qrcode::QrCode;
|
||||
use image::imageops::overlay;
|
||||
use image::{ImageReader, Luma, Rgba, RgbaImage};
|
||||
use qrcode::{QrCode, EcLevel};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
|
||||
/// 渲染基础黑白二维码
|
||||
/// 渲染二维码
|
||||
///
|
||||
/// 根据配置生成黑白二维码图片
|
||||
/// 根据配置生成二维码图片,支持颜色、形状和 Logo
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
@@ -27,7 +27,7 @@ use std::io::Cursor;
|
||||
///
|
||||
/// - 二维码内容为空时返回 `InvalidData`
|
||||
/// - 二维码生成失败时返回相应错误
|
||||
pub fn render_basic_qr(config: &QrConfig) -> AppResult<RgbaImage> {
|
||||
pub fn render_qr(config: &QrConfig) -> AppResult<RgbaImage> {
|
||||
// 验证内容
|
||||
if config.content.trim().is_empty() {
|
||||
return Err(AppError::InvalidData(
|
||||
@@ -37,61 +37,324 @@ pub fn render_basic_qr(config: &QrConfig) -> AppResult<RgbaImage> {
|
||||
|
||||
// 解析容错级别
|
||||
let ec_level = match config.error_correction.as_str() {
|
||||
"L" => qrcode::EcLevel::L,
|
||||
"M" => qrcode::EcLevel::M,
|
||||
"Q" => qrcode::EcLevel::Q,
|
||||
"H" => qrcode::EcLevel::H,
|
||||
_ => qrcode::EcLevel::M, // 默认使用 M 级别
|
||||
"L" => EcLevel::L,
|
||||
"M" => EcLevel::M,
|
||||
"Q" => EcLevel::Q,
|
||||
"H" => EcLevel::H,
|
||||
_ => EcLevel::M,
|
||||
};
|
||||
|
||||
// 生成二维码
|
||||
let qr_code = QrCode::with_error_correction_level(config.content.as_bytes(), ec_level)
|
||||
.map_err(|e| AppError::InvalidData(format!("二维码生成失败: {}", e)))?;
|
||||
|
||||
// 计算包含边距的尺寸
|
||||
// 获取样式配置
|
||||
let style = config.style.as_ref().cloned().unwrap_or_default();
|
||||
|
||||
// 生成基础图像
|
||||
let mut img = if style.is_gradient {
|
||||
render_gradient_qr(&qr_code, config, &style)?
|
||||
} else {
|
||||
render_solid_color_qr(&qr_code, config, &style)?
|
||||
};
|
||||
|
||||
// 叠加 Logo
|
||||
if let Some(logo_config) = &config.logo {
|
||||
overlay_logo(&mut img, logo_config)?;
|
||||
}
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// 渲染纯色二维码
|
||||
fn render_solid_color_qr(
|
||||
qr_code: &QrCode,
|
||||
config: &QrConfig,
|
||||
style: &QrStyle,
|
||||
) -> AppResult<RgbaImage> {
|
||||
let qr_size = qr_code.width() as u32;
|
||||
let total_size = qr_size + 2 * config.margin;
|
||||
|
||||
// 创建图像缓冲区(Luma 格式,用于生成二维码)
|
||||
let qr_image = qr_code.render::<Luma<u8>>().quiet_zone(false).min_dimensions(total_size, total_size).max_dimensions(total_size, total_size).build();
|
||||
// 创建基础图像
|
||||
let qr_image = qr_code
|
||||
.render::<Luma<u8>>()
|
||||
.quiet_zone(false)
|
||||
.min_dimensions(total_size, total_size)
|
||||
.max_dimensions(total_size, total_size)
|
||||
.build();
|
||||
|
||||
// 获取二维码图像尺寸
|
||||
let (width, height) = qr_image.dimensions();
|
||||
|
||||
// 计算缩放比例以匹配目标尺寸
|
||||
let scale = (config.size as f32 / width as f32).max(1.0) as u32;
|
||||
let scaled_width = width * scale;
|
||||
let scaled_height = height * scale;
|
||||
|
||||
// 创建 RGBA 图像并填充白色背景
|
||||
let mut img = RgbaImage::new(scaled_width, scaled_height);
|
||||
for pixel in img.pixels_mut() {
|
||||
*pixel = Rgba([255, 255, 255, 255]);
|
||||
}
|
||||
// 解析颜色
|
||||
let bg_color = parse_hex_color(&style.background_color);
|
||||
let fg_color = parse_hex_color(&style.foreground_color);
|
||||
|
||||
// 渲染二维码
|
||||
// 创建 RGBA 图像
|
||||
let mut img = RgbaImage::new(scaled_width, scaled_height);
|
||||
|
||||
// 渲染每个模块
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = qr_image.get_pixel(x, y);
|
||||
// 如果是黑色像素
|
||||
if pixel[0] == 0 {
|
||||
// 计算缩放后的区域
|
||||
let start_x = x * scale;
|
||||
let start_y = y * scale;
|
||||
let end_x = start_x + scale;
|
||||
let end_y = start_y + scale;
|
||||
let is_dark = pixel[0] == 0;
|
||||
let color = if is_dark { fg_color } else { bg_color };
|
||||
|
||||
// 绘制黑色矩形
|
||||
for py in start_y..end_y.min(scaled_height) {
|
||||
for px in start_x..end_x.min(scaled_width) {
|
||||
img.put_pixel(px, py, Rgba([0, 0, 0, 255]));
|
||||
// 计算缩放后的区域
|
||||
let start_x = x * scale;
|
||||
let start_y = y * scale;
|
||||
let end_x = start_x + scale;
|
||||
let end_y = start_y + scale;
|
||||
|
||||
// 绘制模块
|
||||
draw_shape(
|
||||
&mut img,
|
||||
start_x,
|
||||
start_y,
|
||||
end_x,
|
||||
end_y,
|
||||
color,
|
||||
&style.dot_shape,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// 渲染渐变二维码
|
||||
fn render_gradient_qr(
|
||||
qr_code: &QrCode,
|
||||
config: &QrConfig,
|
||||
style: &QrStyle,
|
||||
) -> AppResult<RgbaImage> {
|
||||
let qr_size = qr_code.width() as u32;
|
||||
let total_size = qr_size + 2 * config.margin;
|
||||
|
||||
// 创建基础图像
|
||||
let qr_image = qr_code
|
||||
.render::<Luma<u8>>()
|
||||
.quiet_zone(false)
|
||||
.min_dimensions(total_size, total_size)
|
||||
.max_dimensions(total_size, total_size)
|
||||
.build();
|
||||
|
||||
let (width, height) = qr_image.dimensions();
|
||||
let scale = (config.size as f32 / width as f32).max(1.0) as u32;
|
||||
let scaled_width = width * scale;
|
||||
let scaled_height = height * scale;
|
||||
|
||||
// 解析背景色
|
||||
let bg_color = parse_hex_color(&style.background_color);
|
||||
|
||||
// 获取渐变颜色
|
||||
let gradient_colors = style.gradient_colors.as_ref();
|
||||
let start_color = gradient_colors
|
||||
.and_then(|colors| colors.first())
|
||||
.map(|c| parse_hex_color(c))
|
||||
.unwrap_or(parse_hex_color(&style.foreground_color));
|
||||
let end_color = gradient_colors
|
||||
.and_then(|colors| colors.get(1))
|
||||
.map(|c| parse_hex_color(c))
|
||||
.unwrap_or(start_color);
|
||||
|
||||
// 创建 RGBA 图像
|
||||
let mut img = RgbaImage::new(scaled_width, scaled_height);
|
||||
|
||||
// 渲染每个模块
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = qr_image.get_pixel(x, y);
|
||||
let is_dark = pixel[0] == 0;
|
||||
|
||||
// 计算渐变颜色
|
||||
let progress = (x as f32 / width as f32).max(0.0).min(1.0);
|
||||
let color = if is_dark {
|
||||
interpolate_color(start_color, end_color, progress)
|
||||
} else {
|
||||
bg_color
|
||||
};
|
||||
|
||||
// 计算缩放后的区域
|
||||
let start_x = x * scale;
|
||||
let start_y = y * scale;
|
||||
let end_x = start_x + scale;
|
||||
let end_y = start_y + scale;
|
||||
|
||||
// 绘制模块
|
||||
draw_shape(
|
||||
&mut img,
|
||||
start_x,
|
||||
start_y,
|
||||
end_x,
|
||||
end_y,
|
||||
color,
|
||||
&style.dot_shape,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// 绘制形状模块
|
||||
fn draw_shape(
|
||||
img: &mut RgbaImage,
|
||||
start_x: u32,
|
||||
start_y: u32,
|
||||
end_x: u32,
|
||||
end_y: u32,
|
||||
color: [u8; 4],
|
||||
shape: &str,
|
||||
) {
|
||||
let (width, height) = img.dimensions();
|
||||
let end_x = end_x.min(width);
|
||||
let end_y = end_y.min(height);
|
||||
|
||||
match shape {
|
||||
"circle" => {
|
||||
// 绘制圆形
|
||||
let center_x = (start_x + end_x) as f32 / 2.0;
|
||||
let center_y = (start_y + end_y) as f32 / 2.0;
|
||||
let radius = ((end_x - start_x) as f32 / 2.0).min((end_y - start_y) as f32 / 2.0);
|
||||
|
||||
for py in start_y..end_y {
|
||||
for px in start_x..end_x {
|
||||
let dx = px as f32 - center_x;
|
||||
let dy = py as f32 - center_y;
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
img.put_pixel(px, py, Rgba(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"rounded" => {
|
||||
// 绘制圆角矩形
|
||||
let radius = ((end_x - start_x) as f32 * 0.3) as u32;
|
||||
|
||||
for py in start_y..end_y {
|
||||
for px in start_x..end_x {
|
||||
let mut should_draw = true;
|
||||
|
||||
// 检查四个角
|
||||
if px < start_x + radius && py < start_y + radius {
|
||||
// 左上角
|
||||
let dx = (start_x + radius - px) as f32;
|
||||
let dy = (start_y + radius - py) as f32;
|
||||
should_draw = dx * dx + dy * dy >= (radius as f32).powi(2) - 1.0;
|
||||
} else if px >= end_x - radius && py < start_y + radius {
|
||||
// 右上角
|
||||
let dx = (px - (end_x - radius)) as f32;
|
||||
let dy = (start_y + radius - py) as f32;
|
||||
should_draw = dx * dx + dy * dy >= (radius as f32).powi(2) - 1.0;
|
||||
} else if px < start_x + radius && py >= end_y - radius {
|
||||
// 左下角
|
||||
let dx = (start_x + radius - px) as f32;
|
||||
let dy = (py - (end_y - radius)) as f32;
|
||||
should_draw = dx * dx + dy * dy >= (radius as f32).powi(2) - 1.0;
|
||||
} else if px >= end_x - radius && py >= end_y - radius {
|
||||
// 右下角
|
||||
let dx = (px - (end_x - radius)) as f32;
|
||||
let dy = (py - (end_y - radius)) as f32;
|
||||
should_draw = dx * dx + dy * dy >= (radius as f32).powi(2) - 1.0;
|
||||
}
|
||||
|
||||
if should_draw {
|
||||
img.put_pixel(px, py, Rgba(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 默认绘制矩形
|
||||
for py in start_y..end_y {
|
||||
for px in start_x..end_x {
|
||||
img.put_pixel(px, py, Rgba(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 叠加 Logo
|
||||
fn overlay_logo(
|
||||
img: &mut RgbaImage,
|
||||
logo_config: &crate::models::qrcode::LogoConfig,
|
||||
) -> AppResult<()> {
|
||||
// 读取 Logo 图片
|
||||
let logo_path = Path::new(&logo_config.path);
|
||||
let logo_img = ImageReader::open(logo_path)
|
||||
.map_err(|e| AppError::IoError(format!("无法读取 Logo 文件: {}", e)))?
|
||||
.decode()
|
||||
.map_err(|e| AppError::IoError(format!("Logo 解码失败: {}", e)))?;
|
||||
|
||||
// 计算 Logo 尺寸
|
||||
let (img_width, img_height) = img.dimensions();
|
||||
let logo_max_size = (img_width.min(img_height) as f32 * logo_config.scale) as u32;
|
||||
|
||||
// 调整 Logo 尺寸
|
||||
let logo_resized = logo_img.resize(
|
||||
logo_max_size,
|
||||
logo_max_size,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
);
|
||||
|
||||
// 转换为 RGBA
|
||||
let logo_rgba = logo_resized.to_rgba8();
|
||||
|
||||
// 计算居中位置
|
||||
let logo_x = ((img_width - logo_max_size) / 2) as i64;
|
||||
let logo_y = ((img_height - logo_max_size) / 2) as i64;
|
||||
|
||||
// 添加白色边框
|
||||
if logo_config.has_border {
|
||||
let border_size = logo_config.border_width;
|
||||
let border_color = Rgba([255, 255, 255, 255]);
|
||||
|
||||
// 绘制边框
|
||||
let y_start = (logo_y - border_size as i64).max(0) as u32;
|
||||
let y_end = (logo_y + logo_max_size as i64 + border_size as i64).min(img_height as i64) as u32;
|
||||
let x_start = (logo_x - border_size as i64).max(0) as u32;
|
||||
let x_end = (logo_x + logo_max_size as i64 + border_size as i64).min(img_width as i64) as u32;
|
||||
|
||||
for y in y_start..y_end {
|
||||
for x in x_start..x_end {
|
||||
let is_border = x < logo_x as u32
|
||||
|| x >= (logo_x + logo_max_size as i64) as u32
|
||||
|| y < logo_y as u32
|
||||
|| y >= (logo_y + logo_max_size as i64) as u32;
|
||||
if is_border {
|
||||
img.put_pixel(x, y, border_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(img)
|
||||
// 叠加 Logo
|
||||
overlay(img, &logo_rgba, logo_x, logo_y);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 Hex 颜色
|
||||
fn parse_hex_color(hex: &str) -> [u8; 4] {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
/// 插值颜色
|
||||
fn interpolate_color(start: [u8; 4], end: [u8; 4], progress: f32) -> [u8; 4] {
|
||||
[
|
||||
(start[0] as f32 + (end[0] as f32 - start[0] as f32) * progress) as u8,
|
||||
(start[1] as f32 + (end[1] as f32 - start[1] as f32) * progress) as u8,
|
||||
(start[2] as f32 + (end[2] as f32 - start[2] as f32) * progress) as u8,
|
||||
255,
|
||||
]
|
||||
}
|
||||
|
||||
/// 将图片转换为 Base64 字符串
|
||||
@@ -120,6 +383,7 @@ pub fn image_to_base64(img: &RgbaImage) -> AppResult<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::qrcode::{QrConfig, QrStyle};
|
||||
|
||||
#[test]
|
||||
fn test_render_basic_qr() {
|
||||
@@ -128,26 +392,46 @@ mod tests {
|
||||
size: 512,
|
||||
margin: 4,
|
||||
error_correction: "M".to_string(),
|
||||
style: None,
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = render_basic_qr(&config);
|
||||
let result = render_qr(&config);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let img = result.unwrap();
|
||||
assert_eq!(img.dimensions().0, 512);
|
||||
assert_eq!(img.dimensions().1, 512);
|
||||
assert!(img.dimensions().0 >= 512);
|
||||
assert!(img.dimensions().1 >= 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_empty_content() {
|
||||
fn test_render_colored_qr() {
|
||||
let style = QrStyle {
|
||||
dot_shape: "circle".to_string(),
|
||||
eye_shape: "square".to_string(),
|
||||
foreground_color: "#FF0000".to_string(),
|
||||
background_color: "#FFFF00".to_string(),
|
||||
is_gradient: false,
|
||||
gradient_colors: None,
|
||||
};
|
||||
|
||||
let config = QrConfig {
|
||||
content: "".to_string(),
|
||||
content: "https://example.com".to_string(),
|
||||
size: 512,
|
||||
margin: 4,
|
||||
error_correction: "M".to_string(),
|
||||
style: Some(style),
|
||||
logo: None,
|
||||
};
|
||||
|
||||
let result = render_basic_qr(&config);
|
||||
assert!(result.is_err());
|
||||
let result = render_qr(&config);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hex_color() {
|
||||
assert_eq!(parse_hex_color("#000000"), [0, 0, 0, 255]);
|
||||
assert_eq!(parse_hex_color("#FFFFFF"), [255, 255, 255, 255]);
|
||||
assert_eq!(parse_hex_color("#FF0000"), [255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CmdRs",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.shenjianz.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "CmdRs - 功能集合",
|
||||
"label": "main",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"alwaysOnTop": false,
|
||||
"skipTaskbar": false,
|
||||
"visible": false,
|
||||
"center": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CmdRs",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.shenjianz.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "CmdRs - 功能集合",
|
||||
"label": "main",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"alwaysOnTop": false,
|
||||
"skipTaskbar": false,
|
||||
"visible": false,
|
||||
"center": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user