feat: 彩色 QR 码 — 自定义前景色/背景色
- QrConfig 新增 fg_color/bg_color 字段(CSS 十六进制格式) - QrCode 存储解析后的 [u8;3] RGB - PNG 渲染 Luma→Rgba,支持 RGBA 颜色 - SVG 渲染使用 QrCode 颜色字段 - CLI 新增 --fg/--bg 参数 - 新增 parse_hex_color 支持 #RRGGBB 和 #RGB - 新增 2 个颜色测试(74 tests total)
This commit is contained in:
@@ -39,6 +39,14 @@ struct Args {
|
|||||||
/// 反色(黑底白码)
|
/// 反色(黑底白码)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
invert: bool,
|
invert: bool,
|
||||||
|
|
||||||
|
/// 前景色 "#RRGGBB" [default: "#000000"]
|
||||||
|
#[arg(long)]
|
||||||
|
fg: Option<String>,
|
||||||
|
|
||||||
|
/// 背景色 "#RRGGBB" [default: "#FFFFFF"]
|
||||||
|
#[arg(long)]
|
||||||
|
bg: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
@@ -80,6 +88,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
level,
|
level,
|
||||||
version,
|
version,
|
||||||
margin: args.margin,
|
margin: args.margin,
|
||||||
|
fg_color: args.fg.clone(),
|
||||||
|
bg_color: args.bg.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
||||||
|
|||||||
+74
-1
@@ -63,15 +63,21 @@ pub struct QrConfig {
|
|||||||
pub version: VersionMode,
|
pub version: VersionMode,
|
||||||
/// 静区边距(模块数),默认 4
|
/// 静区边距(模块数),默认 4
|
||||||
pub margin: u8,
|
pub margin: u8,
|
||||||
|
/// 前景色(CSS 格式 "#RRGGBB"),默认 "#000000"
|
||||||
|
pub fg_color: Option<String>,
|
||||||
|
/// 背景色(CSS 格式 "#RRGGBB"),默认 "#FFFFFF"
|
||||||
|
pub bg_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for QrConfig {
|
impl Default for QrConfig {
|
||||||
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距
|
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距 + 黑前景白背景
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
QrConfig {
|
QrConfig {
|
||||||
level: EcLevel::M,
|
level: EcLevel::M,
|
||||||
version: VersionMode::Auto,
|
version: VersionMode::Auto,
|
||||||
margin: 4,
|
margin: 4,
|
||||||
|
fg_color: None,
|
||||||
|
bg_color: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +114,10 @@ pub struct QrCode {
|
|||||||
matrix: Matrix,
|
matrix: Matrix,
|
||||||
/// 静区边距(模块数)
|
/// 静区边距(模块数)
|
||||||
pub margin: u8,
|
pub margin: u8,
|
||||||
|
/// 前景色 RGB [r, g, b]
|
||||||
|
pub fg_color: [u8; 3],
|
||||||
|
/// 背景色 RGB [r, g, b]
|
||||||
|
pub bg_color: [u8; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QrCode {
|
impl QrCode {
|
||||||
@@ -203,12 +213,17 @@ impl QrCode {
|
|||||||
place_version_info(&mut final_matrix, ver_info);
|
place_version_info(&mut final_matrix, ver_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fg_color = parse_hex_color(config.fg_color.as_deref().unwrap_or("#000000"))?;
|
||||||
|
let bg_color = parse_hex_color(config.bg_color.as_deref().unwrap_or("#FFFFFF"))?;
|
||||||
|
|
||||||
Ok(QrCode {
|
Ok(QrCode {
|
||||||
version,
|
version,
|
||||||
level: config.level,
|
level: config.level,
|
||||||
mask: best_idx,
|
mask: best_idx,
|
||||||
matrix: final_matrix,
|
matrix: final_matrix,
|
||||||
margin: config.margin,
|
margin: config.margin,
|
||||||
|
fg_color,
|
||||||
|
bg_color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +286,39 @@ impl QrCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析 CSS 十六进制颜色 → [R, G, B]
|
||||||
|
///
|
||||||
|
/// 支持格式: "#RGB", "#RRGGBB"
|
||||||
|
/// 无效格式返回 Err
|
||||||
|
fn parse_hex_color(s: &str) -> Result<[u8; 3], String> {
|
||||||
|
let s = s.trim();
|
||||||
|
if !s.starts_with('#') {
|
||||||
|
return Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s));
|
||||||
|
}
|
||||||
|
let hex = &s[1..];
|
||||||
|
match hex.len() {
|
||||||
|
3 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
|
||||||
|
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
|
||||||
|
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
|
||||||
|
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
Ok([r, g, b])
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let r =
|
||||||
|
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
let g =
|
||||||
|
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
let b =
|
||||||
|
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||||
|
Ok([r, g, b])
|
||||||
|
}
|
||||||
|
_ => Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -314,4 +362,29 @@ mod tests {
|
|||||||
assert!(qr.size() >= 21);
|
assert!(qr.size() >= 21);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hex_color() {
|
||||||
|
assert_eq!(parse_hex_color("#000000").unwrap(), [0, 0, 0]);
|
||||||
|
assert_eq!(parse_hex_color("#FFFFFF").unwrap(), [255, 255, 255]);
|
||||||
|
assert_eq!(parse_hex_color("#FF5733").unwrap(), [255, 87, 51]);
|
||||||
|
assert_eq!(parse_hex_color("#F53").unwrap(), [255, 85, 51]);
|
||||||
|
assert!(parse_hex_color("invalid").is_err());
|
||||||
|
assert!(parse_hex_color("#GGGGGG").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_color_qr() {
|
||||||
|
let config = QrConfig {
|
||||||
|
fg_color: Some("#FF0000".into()),
|
||||||
|
bg_color: Some("#0000FF".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
assert!(svg.contains("#FF0000"));
|
||||||
|
assert!(svg.contains("#0000FF"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-7
@@ -1,20 +1,26 @@
|
|||||||
use crate::qr::QrCode;
|
use crate::qr::QrCode;
|
||||||
use image::{ImageBuffer, Luma};
|
use image::{ImageBuffer, Rgba};
|
||||||
|
|
||||||
/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块)
|
/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块)
|
||||||
fn fill_module(
|
fn fill_module(
|
||||||
img: &mut ImageBuffer<Luma<u8>, Vec<u8>>,
|
img: &mut ImageBuffer<Rgba<u8>, Vec<u8>>,
|
||||||
x: u32,
|
x: u32,
|
||||||
y: u32,
|
y: u32,
|
||||||
module_size: u32,
|
module_size: u32,
|
||||||
is_dark: bool,
|
is_dark: bool,
|
||||||
|
fg: &[u8; 3],
|
||||||
|
bg: &[u8; 3],
|
||||||
) {
|
) {
|
||||||
let px_val = if is_dark { 0u8 } else { 255u8 };
|
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 x0 = x * module_size;
|
||||||
let y0 = y * module_size;
|
let y0 = y * module_size;
|
||||||
for dy in 0..module_size {
|
for dy in 0..module_size {
|
||||||
for dx in 0..module_size {
|
for dx in 0..module_size {
|
||||||
img.put_pixel(x0 + dx, y0 + dy, Luma([px_val]));
|
img.put_pixel(x0 + dx, y0 + dy, color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +35,6 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
|
|||||||
|
|
||||||
for y in 0..total_size {
|
for y in 0..total_size {
|
||||||
for x in 0..total_size {
|
for x in 0..total_size {
|
||||||
// 直接比较坐标与 margin 边界,避免 saturating_sub 在边界处回绕到 0
|
|
||||||
let is_dark = if x >= margin
|
let is_dark = if x >= margin
|
||||||
&& x < margin + matrix_size
|
&& x < margin + matrix_size
|
||||||
&& y >= margin
|
&& y >= margin
|
||||||
@@ -39,10 +44,18 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
|
|||||||
let my = (y - margin) as usize;
|
let my = (y - margin) as usize;
|
||||||
qr.modules()[my][mx]
|
qr.modules()[my][mx]
|
||||||
} else {
|
} else {
|
||||||
false // 白边 (quiet zone)
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
fill_module(&mut img, x, y, module_size as u32, is_dark);
|
fill_module(
|
||||||
|
&mut img,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
module_size as u32,
|
||||||
|
is_dark,
|
||||||
|
&qr.fg_color,
|
||||||
|
&qr.bg_color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -5,7 +5,15 @@ pub fn render_svg(qr: &QrCode) -> String {
|
|||||||
let margin = qr.margin as u32;
|
let margin = qr.margin as u32;
|
||||||
let total = matrix_size + 2 * margin;
|
let total = matrix_size + 2 * margin;
|
||||||
|
|
||||||
// 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节
|
let fg = format!(
|
||||||
|
"#{:02X}{:02X}{:02X}",
|
||||||
|
qr.fg_color[0], qr.fg_color[1], qr.fg_color[2]
|
||||||
|
);
|
||||||
|
let bg = format!(
|
||||||
|
"#{:02X}{:02X}{:02X}",
|
||||||
|
qr.bg_color[0], qr.bg_color[1], qr.bg_color[2]
|
||||||
|
);
|
||||||
|
|
||||||
let dark_count = qr
|
let dark_count = qr
|
||||||
.modules()
|
.modules()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -14,19 +22,17 @@ pub fn render_svg(qr: &QrCode) -> String {
|
|||||||
.count();
|
.count();
|
||||||
let mut svg = String::with_capacity(200 + dark_count * 50);
|
let mut svg = String::with_capacity(200 + dark_count * 50);
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
|
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="{total}" viewBox="0 0 {total} {total}">"#
|
||||||
total, total, total, total
|
|
||||||
));
|
));
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r#"<rect width="{}" height="{}" fill="white"/>"#,
|
r#"<rect width="{total}" height="{total}" fill="{bg}"/>"#
|
||||||
total, total
|
|
||||||
));
|
));
|
||||||
|
|
||||||
for y in 0..matrix_size {
|
for y in 0..matrix_size {
|
||||||
for x in 0..matrix_size {
|
for x in 0..matrix_size {
|
||||||
if qr.modules()[y as usize][x as usize] {
|
if qr.modules()[y as usize][x as usize] {
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r#"<rect x="{}" y="{}" width="1" height="1" fill="black"/>"#,
|
r#"<rect x="{}" y="{}" width="1" height="1" fill="{fg}"/>"#,
|
||||||
x + margin,
|
x + margin,
|
||||||
y + margin
|
y + margin
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, Stri
|
|||||||
level: ec_level,
|
level: ec_level,
|
||||||
version: VersionMode::Auto,
|
version: VersionMode::Auto,
|
||||||
margin,
|
margin,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||||
@@ -78,6 +79,7 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
|
|||||||
level: ec_level,
|
level: ec_level,
|
||||||
version: VersionMode::Auto,
|
version: VersionMode::Auto,
|
||||||
margin,
|
margin,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
|
|||||||
level,
|
level,
|
||||||
version: VersionMode::Auto,
|
version: VersionMode::Auto,
|
||||||
margin: params.margin,
|
margin: params.margin,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let qr = match QrCode::encode(¶ms.text, config) {
|
let qr = match QrCode::encode(¶ms.text, config) {
|
||||||
|
|||||||
Reference in New Issue
Block a user