pub const PX_PER_MM: f64 = 8.0;
pub const HEAD_WIDE_PX: u32 = 384;
pub const HEAD_NARROW_PX: u32 = 96;
pub const MAX_DIMENSION_MM: f64 = 1000.0;
pub fn mm_to_px(mm: f64, pixels_per_mm: f64) -> u32 {
if !mm.is_finite() || !pixels_per_mm.is_finite() || pixels_per_mm <= 0.0 {
return 1;
}
(mm * pixels_per_mm)
.round()
.clamp(1.0, (MAX_DIMENSION_MM * pixels_per_mm).max(1.0)) as u32
}
pub fn px_to_mm(px: u32, pixels_per_mm: f64) -> f64 {
px as f64 / pixels_per_mm
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LabelMm {
pub width_mm: f64,
pub height_mm: f64,
}
impl LabelMm {
pub fn new(width_mm: f64, height_mm: f64) -> Self {
Self {
width_mm,
height_mm,
}
}
pub fn parse(s: &str) -> crate::errors::Result<Self> {
use crate::errors::Error;
let s = s.trim().to_ascii_lowercase().replace(['×', '*'], "x");
let mut parts = s.split('x');
let w = parts
.next()
.and_then(|p| p.trim().parse::<f64>().ok())
.ok_or_else(|| {
Error::invalid_label(format!("bad label size '{s}', expected e.g. 50x30"))
})?;
let h = parts
.next()
.and_then(|p| p.trim().parse::<f64>().ok())
.ok_or_else(|| {
Error::invalid_label(format!("bad label size '{s}', expected e.g. 50x30"))
})?;
if parts.next().is_some() {
return Err(Error::invalid_label(format!(
"bad label size '{s}', expected exactly two dimensions, e.g. 50x30"
)));
}
for (value, axis) in [(w, "width"), (h, "height")] {
if !value.is_finite() || value <= 0.0 {
return Err(Error::invalid_label(format!(
"label {axis} must be a positive number of mm, got '{value}'"
)));
}
if value > MAX_DIMENSION_MM {
return Err(Error::invalid_label(format!(
"label {axis} {value} mm exceeds the {MAX_DIMENSION_MM} mm limit"
)));
}
}
Ok(Self::new(w, h))
}
pub fn to_pixels(self, max_width_px: u32, pixels_per_mm: f64) -> LabelPx {
let w = mm_to_px(self.width_mm, pixels_per_mm)
.checked_next_multiple_of(8)
.unwrap_or(max_width_px)
.min(max_width_px);
LabelPx {
width_px: w.max(8),
height_px: mm_to_px(self.height_mm, pixels_per_mm).max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SafeArea {
pub top: u32,
pub bottom: u32,
pub left: u32,
pub right: u32,
}
impl SafeArea {
pub const B1: Self = Self {
top: 8,
bottom: 8,
left: 0,
right: 0,
};
pub const NONE: Self = Self {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
pub fn from_mm(top: f64, bottom: f64, left: f64, right: f64) -> Self {
Self::from_mm_at(top, bottom, left, right, PX_PER_MM)
}
pub fn from_mm_at(top: f64, bottom: f64, left: f64, right: f64, pixels_per_mm: f64) -> Self {
let px = |mm: f64| (mm.max(0.0) * pixels_per_mm).round() as u32;
Self {
top: px(top),
bottom: px(bottom),
left: px(left),
right: px(right),
}
}
pub fn registration(pixels_per_mm: f64) -> Self {
Self::from_mm_at(1.0, 1.0, 0.0, 0.0, pixels_per_mm)
}
pub fn content(self, label: LabelPx) -> Option<Rect> {
let horizontal = self.left.checked_add(self.right)?;
let vertical = self.top.checked_add(self.bottom)?;
let w = label.width_px.checked_sub(horizontal)?;
let h = label.height_px.checked_sub(vertical)?;
(w > 0 && h > 0).then_some(Rect {
x: self.left,
y: self.top,
w,
h,
})
}
}
impl Default for SafeArea {
fn default() -> Self {
Self::B1
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LabelPx {
pub width_px: u32,
pub height_px: u32,
}
impl LabelPx {
pub fn mm(self, pixels_per_mm: f64) -> LabelMm {
LabelMm::new(
px_to_mm(self.width_px, pixels_per_mm),
px_to_mm(self.height_px, pixels_per_mm),
)
}
}
pub const DEFAULT_B1_LABEL: LabelMm = LabelMm {
width_mm: 50.0,
height_mm: 30.0,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_resolution_does_not_panic_or_produce_zero_pixels() {
for scale in [0.0, -1.0, f64::NAN, f64::INFINITY, 0.0001] {
assert_eq!(mm_to_px(30.0, scale), 1, "scale: {scale}");
}
}
#[test]
fn fifty_by_thirty() {
let l = LabelMm::parse("50x30").unwrap();
let p = l.to_pixels(384, 8.0);
assert_eq!(p.width_px, 384);
assert_eq!(p.height_px, 240);
}
#[test]
fn parse_variants() {
assert_eq!(LabelMm::parse("40×20").unwrap().height_mm, 20.0);
assert_eq!(LabelMm::parse("30*15").unwrap().width_mm, 30.0);
}
#[test]
fn width_multiple_of_eight() {
let p = LabelMm::new(49.0, 30.0).to_pixels(384, 8.0);
assert_eq!(p.width_px % 8, 0);
}
#[test]
fn mm_px_roundtrip_approx() {
let px = mm_to_px(30.0, 8.0);
assert_eq!(px, 240);
assert!((px_to_mm(240, 8.0) - 30.0).abs() < 0.01);
}
#[test]
fn three_hundred_dpi_geometry_is_not_forced_to_eight_px_per_mm() {
let profile = crate::profile::profile_for_model(crate::Model::D11H);
let pixels =
LabelMm::new(15.0, 30.0).to_pixels(profile.max_width_px, profile.pixels_per_mm());
assert_eq!(pixels.width_px, 142);
assert_eq!(pixels.height_px, 354);
}
#[test]
fn registration_margin_tracks_profile_resolution() {
assert_eq!(SafeArea::registration(8.0), SafeArea::B1);
let area = SafeArea::registration(300.0 / 25.4);
assert_eq!(
(area.top, area.bottom, area.left, area.right),
(12, 12, 0, 0)
);
}
#[test]
fn reject_bad_sizes() {
assert!(LabelMm::parse("").is_err());
assert!(LabelMm::parse("abc").is_err());
assert!(LabelMm::parse("0x30").is_err());
assert!(LabelMm::parse("-10x20").is_err());
}
#[test]
fn reject_extra_dimensions_and_trailing_separators() {
for s in [
"50x30x999",
"50x30x",
"50×30×999",
"50×30×",
"50*30*999",
"50*30*",
] {
assert!(LabelMm::parse(s).is_err(), "should reject '{s}'");
}
}
#[test]
fn reject_non_finite_dimensions() {
for s in ["infx30", "50xinf", "nanx30", "50xnan", "-infx30"] {
assert!(LabelMm::parse(s).is_err(), "should reject '{s}'");
}
}
#[test]
fn reject_absurdly_large_dimensions() {
assert!(LabelMm::parse("1e9x30").is_err());
assert!(LabelMm::parse("50x1e9").is_err());
assert!(LabelMm::parse("999x999").is_ok());
}
#[test]
fn to_pixels_cannot_overflow_for_any_input() {
for mm in [f64::INFINITY, f64::NAN, f64::MAX, 1e300, -1.0] {
let p = LabelMm::new(mm, mm).to_pixels(384, 8.0);
assert!(p.width_px.is_multiple_of(8) && p.width_px <= 384);
assert!(p.height_px >= 1);
}
}
#[test]
fn safe_area_insets_correctly() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let safe = SafeArea::B1;
let area = safe.content(lp).unwrap();
assert_eq!(area.x, safe.left);
assert_eq!(area.y, safe.top);
assert_eq!(area.w, lp.width_px - safe.left - safe.right);
assert_eq!(area.h, lp.height_px - safe.top - safe.bottom);
assert!(area.x + area.w <= lp.width_px);
assert!(area.y + area.h <= lp.height_px);
}
#[test]
fn safe_area_reports_none_when_it_would_consume_the_label() {
let tiny = LabelPx {
width_px: 8,
height_px: 8,
};
assert!(SafeArea::B1.content(tiny).is_none());
assert!(SafeArea::NONE.content(tiny).is_some());
}
#[test]
fn safe_area_overflow_is_rejected() {
let label = LabelPx {
width_px: 384,
height_px: 240,
};
let hostile = SafeArea {
top: u32::MAX,
bottom: 1,
left: u32::MAX,
right: 1,
};
assert!(hostile.content(label).is_none());
}
#[test]
fn narrow_label_under_max() {
let p = LabelMm::parse("30x20").unwrap().to_pixels(384, 8.0);
assert_eq!(p.width_px, 240);
assert_eq!(p.height_px, 160);
}
}