use crate::errors::{Error, Result};
use crate::font::LabelFont;
use crate::geometry::{LabelPx, Rect, SafeArea};
use image::{GrayImage, Luma};
use qrcode::QrCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[value(rename_all = "lower")]
pub enum TextSide {
#[value(alias = "l")]
Left,
#[value(alias = "r")]
Right,
}
#[derive(Debug, Clone)]
pub struct QrLabelOptions {
pub url: String,
pub side_text: String,
pub label: LabelPx,
pub safe: SafeArea,
pub text_side: TextSide,
pub border: bool,
pub font_path: Option<std::path::PathBuf>,
pub font_name: Option<String>,
pub font_size: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QrLayout {
pub area: Rect,
pub gap: u32,
pub text_col_w: u32,
pub qr_side: u32,
}
const MARGIN: u32 = 4;
const GAP: u32 = 8;
const TEXT_COL_FRACTION: f64 = 0.34;
const TEXT_COL_MIN: u32 = 64;
const QR_SIDE_MIN: u32 = 64;
const MIN_TEXT_BOX_PX: u32 = 16;
pub fn content_box(label: LabelPx, safe: SafeArea) -> Option<Rect> {
let area = safe.content(label)?;
Some(Rect {
x: area.x + MARGIN,
y: area.y + MARGIN,
w: area.w.checked_sub(MARGIN * 2)?,
h: area.h.checked_sub(MARGIN * 2)?,
})
}
pub fn qr_layout(label: LabelPx, safe: SafeArea) -> Option<QrLayout> {
let inner = content_box(label, safe)?;
let ideal = ((label.width_px as f64) * TEXT_COL_FRACTION).round() as u32;
let text_col_w = ideal.max(TEXT_COL_MIN).min(inner.w / 2);
let qr_budget_w = inner.w.saturating_sub(text_col_w + GAP);
let qr_side = qr_budget_w.min(inner.h);
(qr_side >= QR_SIDE_MIN).then_some(QrLayout {
area: inner,
gap: GAP,
text_col_w,
qr_side,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
#[value(rename_all = "lower")]
pub enum TextAlign {
#[value(alias = "l")]
Left,
#[default]
#[value(alias = "c")]
Center,
#[value(alias = "r")]
Right,
}
fn draw_border(img: &mut GrayImage) {
let (w, h) = img.dimensions();
for x in 0..w {
img.put_pixel(x, 0, Luma([0]));
img.put_pixel(x, h - 1, Luma([0]));
}
for y in 0..h {
img.put_pixel(0, y, Luma([0]));
img.put_pixel(w - 1, y, Luma([0]));
}
}
pub const BOUNDARY_SPAN_MM: u32 = 13;
#[derive(Debug, Clone)]
pub struct TextLabelOptions {
pub text: String,
pub label: LabelPx,
pub safe: SafeArea,
pub align: TextAlign,
pub border: bool,
pub font_path: Option<std::path::PathBuf>,
pub font_name: Option<String>,
pub font_size: Option<f32>,
}
fn load_font(path: Option<&std::path::Path>, name: Option<&str>) -> Result<LabelFont> {
match (path, name) {
(Some(p), _) => LabelFont::load(p),
(None, Some(n)) => LabelFont::load_named(n),
(None, None) => LabelFont::load_default(),
}
}
pub const QR_MIN_MODULE_PX: usize = 2;
pub const QR_COMFORTABLE_MODULE_PX: usize = 3;
fn overlay_gray(dst: &mut GrayImage, src: &GrayImage, x0: u32, y0: u32) {
let (dw, dh) = dst.dimensions();
let (sw, sh) = src.dimensions();
for y in 0..sh {
for x in 0..sw {
let dx = x0 + x;
let dy = y0 + y;
if dx < dw && dy < dh {
dst.put_pixel(dx, dy, *src.get_pixel(x, y));
}
}
}
}
pub fn max_qr_side(label: LabelPx, safe: SafeArea) -> u32 {
qr_layout(label, safe).map_or(0, |l| l.qr_side)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::LabelMm;
fn test_font_path() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fonts/DejaVuSans.ttf")
}
#[test]
fn invalid_font_sizes_leave_the_image_unchanged() {
let font = LabelFont::load(&test_font_path()).unwrap();
let original = GrayImage::from_pixel(384, 240, Luma([255]));
let bx = Rect {
x: 4,
y: 12,
w: 376,
h: 216,
};
for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -1.0] {
let mut img = original.clone();
let error =
draw_text_block(&mut img, &font, "HELLO", bx, TextAlign::Center, Some(size))
.unwrap_err();
assert!(error.to_string().contains("finite, positive"));
assert_eq!(img, original);
}
}
#[test]
fn overflowing_text_is_rejected_before_drawing_any_lines() {
let font = LabelFont::load(&test_font_path()).unwrap();
let original = GrayImage::from_pixel(384, 240, Luma([255]));
for (text, bx, size) in [
(
"OVERFLOW\n".repeat(10),
Rect {
x: 4,
y: 12,
w: 376,
h: 216,
},
Some(96.0),
),
(
"OVERFLOW\n".repeat(100),
Rect {
x: 4,
y: 12,
w: 376,
h: 216,
},
None,
),
(
"W".into(),
Rect {
x: 4,
y: 12,
w: 16,
h: 216,
},
Some(96.0),
),
] {
let mut img = original.clone();
let error =
draw_text_block(&mut img, &font, &text, bx, TextAlign::Center, size).unwrap_err();
assert!(error.to_string().contains("text does not fit"));
assert_eq!(img, original, "failed layout must not leave partial text");
}
}
#[test]
fn qr_is_square() {
let side = 200;
let qr = render_qr_square("https://www.youtube.com", side).unwrap();
assert_eq!(qr.dimensions(), (side, side));
}
#[test]
fn label_text_is_left_to_right_abc() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let img = make_qr_label_opts(&QrLabelOptions {
url: "https://www.youtube.com".into(),
side_text: "ABC".into(),
label: lp,
safe: SafeArea::default(),
text_side: TextSide::Right,
border: false,
font_path: Some(test_font_path()),
font_name: None,
font_size: None,
})
.unwrap();
assert_eq!(img.dimensions(), (384, 240));
let mut min_x = 9999u32;
let mut max_x = 0u32;
let mut count = 0u32;
for (x, y, p) in img.enumerate_pixels() {
if x > 200 && p[0] < 128 {
min_x = min_x.min(x);
max_x = max_x.max(x);
count += 1;
let _ = y;
}
}
assert!(count > 50, "expected text ink, got {count}");
assert!(max_x > min_x + 15, "ABC should span horizontally");
}
#[test]
fn narrow_label_errors_instead_of_panicking() {
let lp = LabelPx {
width_px: 96,
height_px: 240,
};
assert_eq!(max_qr_side(lp, SafeArea::default()), 0);
let err = make_qr_label_opts(&QrLabelOptions {
url: "https://example.com".into(),
side_text: "HI".into(),
label: lp,
safe: SafeArea::default(),
text_side: TextSide::Right,
border: false,
font_path: Some(test_font_path()),
font_name: None,
font_size: None,
})
.expect_err("96px label cannot fit QR + text");
assert!(err.to_string().contains("too small"), "{err}");
}
#[test]
fn qr_layout_is_the_only_source_of_layout_math() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let layout = qr_layout(lp, SafeArea::default()).expect("50x30 fits");
assert_eq!(max_qr_side(lp, SafeArea::default()), layout.qr_side);
assert!(layout.text_col_w <= lp.width_px / 2);
assert!(layout.qr_side + layout.text_col_w + layout.gap <= layout.area.w);
assert!(layout.area.y + layout.area.h <= lp.height_px);
}
#[test]
fn qr_layout_never_panics_across_label_sizes() {
for w in (8..=384).step_by(8) {
for h in [8u32, 32, 96, 240, 800] {
let lp = LabelPx {
width_px: w,
height_px: h,
};
if let Some(l) = qr_layout(lp, SafeArea::default()) {
assert!(l.qr_side >= QR_SIDE_MIN);
assert!(l.qr_side <= l.area.h);
}
}
}
}
#[test]
fn qr_rejects_content_too_dense_to_scan() {
let long = "x".repeat(900);
let err = render_qr_square(&long, 200).expect_err("should refuse");
assert!(err.to_string().contains("too fine to scan"), "{err}");
assert!(render_qr_square(&"x".repeat(600), 200).is_ok());
}
#[test]
fn qr_accepts_ordinary_content() {
let img = render_qr_square("https://github.com/kahwee/thermark", 200).unwrap();
assert_eq!(img.dimensions(), (200, 200));
}
#[test]
fn qr_stays_clear_of_the_clipped_bottom_edge() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let layout = qr_layout(lp, SafeArea::default()).unwrap();
let safe = SafeArea::default();
let qr_bottom = layout.area.y + (layout.area.h + layout.qr_side) / 2;
assert!(
qr_bottom <= lp.height_px - safe.bottom,
"QR reaches {qr_bottom}, clipped band starts at {}",
lp.height_px - safe.bottom
);
}
#[test]
fn text_label_renders_and_stays_in_the_safe_area() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let img = make_text_label(&TextLabelOptions {
text: "HELLO\nWORLD".into(),
label: lp,
safe: SafeArea::default(),
align: TextAlign::Center,
border: false,
font_path: Some(test_font_path()),
font_name: None,
font_size: None,
})
.expect("render text label with vendored font");
assert_eq!(img.dimensions(), (lp.width_px, lp.height_px));
let safe = SafeArea::default();
for y in (lp.height_px - safe.bottom)..lp.height_px {
for x in 0..lp.width_px {
assert_eq!(img.get_pixel(x, y)[0], 255, "ink at ({x},{y}) is clipped");
}
}
}
#[test]
fn text_label_rejects_empty_text() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
assert!(
make_text_label(&TextLabelOptions {
text: " ".into(),
label: lp,
safe: SafeArea::default(),
align: TextAlign::Center,
border: false,
font_path: None,
font_name: None,
font_size: None,
})
.is_err()
);
}
#[test]
fn no_border_by_default() {
let lp = LabelPx {
width_px: 384,
height_px: 240,
};
let img = make_qr_label_opts(&QrLabelOptions {
url: "https://example.com".into(),
side_text: "HI".into(),
label: lp,
safe: SafeArea::default(),
text_side: TextSide::Right,
border: false,
font_path: Some(test_font_path()),
font_name: None,
font_size: None,
})
.unwrap();
assert_eq!(img.get_pixel(0, 0)[0], 255);
assert_eq!(img.get_pixel(383, 0)[0], 255);
assert_eq!(img.get_pixel(0, 239)[0], 255);
assert_eq!(img.get_pixel(383, 239)[0], 255);
}
}
mod qr;
pub use qr::{make_qr_label_opts, render_qr_square};
mod text;
pub use text::{draw_text_block, make_text_label};
mod calibration;
pub use calibration::{boundary_range, make_boundary_label, make_calibration_label};
pub use qr::make_qr_label_with_font;
pub use text::make_text_label_with_font;