use crate::errors::{Error, Result};
use crate::geometry::{LabelPx, Rect, SafeArea};
use crate::packet::{MAX_DATA_LEN, Packet};
use crate::protocol;
use crate::types::Rotation;
use image::{DynamicImage, GenericImageView, GrayImage, Luma, RgbaImage, imageops};
const MAX_BITMAP_WIDTH_PX: u32 = ((MAX_DATA_LEN - 6) * 8) as u32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Raster {
width: u32,
height: u32,
rows: Vec<Packet>,
}
impl Raster {
pub fn try_new(width: u32, height: u32, rows: Vec<Packet>) -> Result<Self> {
let raster = Self {
width,
height,
rows,
};
raster.validate()?;
Ok(raster)
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn rows(&self) -> &[Packet] {
&self.rows
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub(crate) fn into_parts(self) -> (u32, u32, Vec<Packet>) {
(self.width, self.height, self.rows)
}
#[cfg(test)]
pub(crate) fn from_parts_unchecked(width: u32, height: u32, rows: Vec<Packet>) -> Self {
Self {
width,
height,
rows,
}
}
pub(crate) fn validate(&self) -> Result<()> {
if self.width == 0 || self.height == 0 {
return Err(Error::InvalidRaster("dimensions must be non-zero".into()));
}
if u16::try_from(self.width).is_err() || u16::try_from(self.height).is_err() {
return Err(Error::ImageTooLarge {
width: self.width,
height: self.height,
});
}
let mut logical_row = 0u32;
for (packet_index, row) in self.rows.iter().enumerate() {
if row.data.len() > MAX_DATA_LEN {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} is too large for the one-byte frame length"
)));
}
let expected_index = (logical_row as u16).to_be_bytes();
if row.data.get(..2) != Some(expected_index.as_slice()) {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} starts at the wrong logical row"
)));
}
let repeats = match row.cmd {
cmd if cmd == protocol::Cmd::PrintEmptyRow as u8 => {
if row.data.len() != 3 || row.data[2] == 0 {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} has an invalid empty-row payload"
)));
}
row.data[2]
}
cmd if cmd == protocol::Cmd::PrintBitmapRow as u8 => {
let pixel_bytes = (self.width as usize).div_ceil(8);
if row.data.len() != 6 + pixel_bytes || row.data[5] == 0 {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} has an invalid bitmap-row payload"
)));
}
row.data[5]
}
_ => {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} uses a non-row command"
)));
}
};
logical_row = logical_row.checked_add(u32::from(repeats)).ok_or_else(|| {
Error::InvalidRaster("row repeat count overflowed page height".into())
})?;
if logical_row > self.height {
return Err(Error::InvalidRaster(format!(
"row packet {packet_index} repeats beyond page height {}",
self.height
)));
}
}
if logical_row != self.height {
return Err(Error::InvalidRaster(format!(
"height is {} but row packets cover {logical_row} logical rows",
self.height
)));
}
Ok(())
}
}
pub fn rotate(img: DynamicImage, rotation: Rotation) -> DynamicImage {
match rotation {
Rotation::Deg0 => img,
Rotation::Deg90 => img.rotate90(),
Rotation::Deg180 => img.rotate180(),
Rotation::Deg270 => img.rotate270(),
}
}
pub fn encode_path(
path: &std::path::Path,
max_width: u32,
threshold: u8,
dither: bool,
) -> Result<Raster> {
let img = image::open(path).map_err(Error::from)?;
encode(img, max_width, threshold, dither)
}
pub fn encode(img: DynamicImage, max_width: u32, threshold: u8, dither: bool) -> Result<Raster> {
let (width, height) = img.dimensions();
validate_encode_dimensions(width, height, max_width)?;
let gray = grayscale_on_white(img);
encode_gray_validated(&gray, width, height, threshold, dither)
}
pub fn grayscale_on_white(img: DynamicImage) -> GrayImage {
if !img.has_alpha() {
return img.into_luma8();
}
flatten_alpha_on_white(img).into_luma8()
}
fn flatten_alpha_on_white(img: DynamicImage) -> DynamicImage {
match img {
DynamicImage::ImageLumaA8(mut pixels) => {
for pixel in pixels.pixels_mut() {
pixel[0] = composite_u8_on_white(pixel[0], pixel[1]);
pixel[1] = u8::MAX;
}
DynamicImage::ImageLumaA8(pixels)
}
DynamicImage::ImageRgba8(mut pixels) => {
for pixel in pixels.pixels_mut() {
let alpha = pixel[3];
for channel in &mut pixel.0[..3] {
*channel = composite_u8_on_white(*channel, alpha);
}
pixel[3] = u8::MAX;
}
DynamicImage::ImageRgba8(pixels)
}
DynamicImage::ImageLumaA16(mut pixels) => {
for pixel in pixels.pixels_mut() {
pixel[0] = composite_u16_on_white(pixel[0], pixel[1]);
pixel[1] = u16::MAX;
}
DynamicImage::ImageLumaA16(pixels)
}
DynamicImage::ImageRgba16(mut pixels) => {
for pixel in pixels.pixels_mut() {
let alpha = pixel[3];
for channel in &mut pixel.0[..3] {
*channel = composite_u16_on_white(*channel, alpha);
}
pixel[3] = u16::MAX;
}
DynamicImage::ImageRgba16(pixels)
}
DynamicImage::ImageRgba32F(mut pixels) => {
for pixel in pixels.pixels_mut() {
let alpha = pixel[3];
for channel in &mut pixel.0[..3] {
*channel = *channel * alpha + (1.0 - alpha);
}
pixel[3] = 1.0;
}
DynamicImage::ImageRgba32F(pixels)
}
image => image,
}
}
fn composite_u8_on_white(channel: u8, alpha: u8) -> u8 {
let alpha = u16::from(alpha);
((u16::from(channel) * alpha + u16::from(u8::MAX) * (u16::from(u8::MAX) - alpha) + 127)
/ u16::from(u8::MAX)) as u8
}
fn composite_u16_on_white(channel: u16, alpha: u16) -> u16 {
let alpha = u64::from(alpha);
((u64::from(channel) * alpha
+ u64::from(u16::MAX) * (u64::from(u16::MAX) - alpha)
+ u64::from(u16::MAX) / 2)
/ u64::from(u16::MAX)) as u16
}
#[inline]
fn burns_at_threshold(luma: u8, threshold: u8) -> bool {
255u8.saturating_sub(luma) > threshold
}
pub fn encode_gray(
gray: &GrayImage,
max_width: u32,
threshold: u8,
dither: bool,
) -> Result<Raster> {
let (width, height) = gray.dimensions();
validate_encode_dimensions(width, height, max_width)?;
encode_gray_validated(gray, width, height, threshold, dither)
}
fn validate_encode_dimensions(width: u32, height: u32, max_width: u32) -> Result<()> {
if width > max_width {
return Err(Error::ImageTooWide {
width,
max: max_width,
});
}
if width == 0 || height == 0 {
return Err(Error::InvalidRaster("dimensions must be non-zero".into()));
}
if width > MAX_BITMAP_WIDTH_PX
|| u16::try_from(width).is_err()
|| u16::try_from(height).is_err()
{
return Err(Error::ImageTooLarge { width, height });
}
Ok(())
}
fn encode_gray_validated(
gray: &GrayImage,
width: u32,
height: u32,
threshold: u8,
dither: bool,
) -> Result<Raster> {
let bytes_per_row = (width as usize).div_ceil(8);
let mut packed = vec![0u8; bytes_per_row];
let mut all_white = true;
let mut runs = RowRunEncoder::new();
for_each_print_bit(gray, threshold, dither, |x, y, burn| {
if x == 0 {
packed.fill(0);
all_white = true;
}
if burn {
all_white = false;
packed[(x / 8) as usize] |= 1 << (7 - (x % 8));
}
if x + 1 == width {
runs.push(y as u16, (!all_white).then_some(&packed));
}
});
Raster::try_new(width, height, runs.finish())
}
pub fn gray_to_print_bits(gray: &GrayImage, threshold: u8, dither: bool) -> GrayImage {
let (w, h) = gray.dimensions();
let mut bw = GrayImage::new(w, h);
for_each_print_bit(gray, threshold, dither, |x, y, burn| {
if burn {
bw.get_pixel_mut(x, y)[0] = 255;
}
});
bw
}
pub fn render_print_preview(
gray: &GrayImage,
max_width: u32,
threshold: u8,
dither: bool,
) -> Result<GrayImage> {
let (width, height) = gray.dimensions();
validate_encode_dimensions(width, height, max_width)?;
let mut preview = GrayImage::from_pixel(width, height, Luma([255]));
for_each_print_bit(gray, threshold, dither, |x, y, burn| {
if burn {
preview.get_pixel_mut(x, y)[0] = 0;
}
});
Ok(preview)
}
fn for_each_print_bit(
gray: &GrayImage,
threshold: u8,
dither: bool,
mut visit: impl FnMut(u32, u32, bool),
) {
let (width, height) = gray.dimensions();
let width_usize = width as usize;
if width == 0 || height == 0 {
return;
}
if !dither {
for (y, row) in gray.as_raw().chunks_exact(width_usize).enumerate() {
for (x, &luma) in row.iter().enumerate() {
visit(x as u32, y as u32, burns_at_threshold(luma, threshold));
}
}
return;
}
let source = gray.as_raw();
let mut current = source[..width_usize]
.iter()
.map(|&luma| f32::from(255u8.saturating_sub(luma)))
.collect::<Vec<_>>();
let mut next = vec![0.0f32; width_usize];
if height > 1 {
initialize_error_row(&mut next, &source[width_usize..width_usize * 2]);
}
let threshold = f32::from(threshold);
for y in 0..height as usize {
for x in 0..width_usize {
let old = current[x];
let burn = old > threshold;
let new = if burn { 255.0 } else { 0.0 };
let error = old - new;
visit(x as u32, y as u32, burn);
if x + 1 < width_usize {
current[x + 1] += error * (7.0 / 16.0);
}
if y + 1 < height as usize {
if x > 0 {
next[x - 1] += error * (3.0 / 16.0);
}
next[x] += error * (5.0 / 16.0);
if x + 1 < width_usize {
next[x + 1] += error * (1.0 / 16.0);
}
}
}
std::mem::swap(&mut current, &mut next);
if y + 2 < height as usize {
let start = (y + 2) * width_usize;
initialize_error_row(&mut next, &source[start..start + width_usize]);
}
}
}
fn initialize_error_row(error_row: &mut [f32], source_row: &[u8]) {
for (error, &luma) in error_row.iter_mut().zip(source_row) {
*error = f32::from(255u8.saturating_sub(luma));
}
}
fn push_row_run(out: &mut Vec<Packet>, start: u16, repeats: u8, pixels: Option<&[u8]>) {
out.push(match pixels {
Some(pixels) => protocol::print_bitmap_row(start, repeats, pixels),
None => protocol::print_empty_row(start, repeats),
});
}
struct RowRunEncoder {
packets: Vec<Packet>,
run_start: u16,
run_pixels: Option<Vec<u8>>,
run_len: u8,
}
impl RowRunEncoder {
fn new() -> Self {
Self {
packets: Vec::new(),
run_start: 0,
run_pixels: None,
run_len: 0,
}
}
fn push(&mut self, row_index: u16, pixels: Option<&[u8]>) {
let matches_run = self.run_len > 0
&& match (&self.run_pixels, pixels) {
(Some(current), Some(next)) => current.as_slice() == next,
(None, None) => true,
_ => false,
};
if matches_run && self.run_len < u8::MAX {
self.run_len += 1;
return;
}
self.flush();
self.run_start = row_index;
self.run_pixels = pixels.map(<[u8]>::to_vec);
self.run_len = 1;
}
fn flush(&mut self) {
if self.run_len > 0 {
push_row_run(
&mut self.packets,
self.run_start,
self.run_len,
self.run_pixels.as_deref(),
);
self.run_len = 0;
}
}
fn finish(mut self) -> Vec<Packet> {
self.flush();
self.packets
}
}
pub fn trim_white(img: DynamicImage, threshold: u8) -> DynamicImage {
let ink = image_luma_bounds(&img, |luma| luma <= threshold);
crop_to_bounds(img, ink)
}
pub(crate) fn trim_for_print(img: DynamicImage, threshold: u8, dither: bool) -> DynamicImage {
let ink = if dither {
image_luma_bounds(&img, |luma| luma < u8::MAX).map(|bounds| Rect {
x: 0,
y: bounds.y,
w: img.width(),
h: bounds.h,
})
} else {
image_luma_bounds(&img, |luma| burns_at_threshold(luma, threshold))
};
crop_to_bounds(img, ink)
}
fn crop_to_bounds(img: DynamicImage, bounds: Option<Rect>) -> DynamicImage {
let (width, height) = img.dimensions();
let Some(bounds) = bounds else {
return img;
};
if bounds.x == 0 && bounds.y == 0 && bounds.w == width && bounds.h == height {
return img;
}
img.crop_imm(bounds.x, bounds.y, bounds.w, bounds.h)
}
fn image_luma_bounds(img: &DynamicImage, predicate: impl FnMut(u8) -> bool) -> Option<Rect> {
if let Some(gray) = img.as_luma8() {
return luma_bounds_by(
gray.enumerate_pixels()
.map(|(x, y, pixel)| (x, y, pixel[0])),
predicate,
);
}
if let Some(rgb) = img.as_rgb8() {
return striped_rgb8_luma_bounds(rgb, predicate);
}
if !img.has_alpha() {
let gray = img.to_luma8();
return luma_bounds_by(
gray.enumerate_pixels()
.map(|(x, y, pixel)| (x, y, pixel[0])),
predicate,
);
}
striped_luma_bounds(img, predicate, grayscale_on_white)
}
fn striped_rgb8_luma_bounds(
rgb: &image::RgbImage,
mut predicate: impl FnMut(u8) -> bool,
) -> Option<Rect> {
const STRIPE_ROWS: u32 = 64;
let (width, height) = rgb.dimensions();
let row_bytes = width as usize * 3;
let color_space = rgb.color_space();
let mut bounds = LumaBounds::new();
let mut y = 0;
while width != 0 && y < height {
let rows = (height - y).min(STRIPE_ROWS);
let start = y as usize * row_bytes;
let end = (y + rows) as usize * row_bytes;
let mut stripe = image::RgbImage::from_raw(width, rows, rgb.as_raw()[start..end].to_vec())
.expect("a source-aligned RGB stripe has valid dimensions");
stripe
.set_color_space(color_space)
.expect("the source RGB image already has a valid color space");
let gray = DynamicImage::ImageRgb8(stripe).into_luma8();
for (x, stripe_y, pixel) in gray.enumerate_pixels() {
if predicate(pixel[0]) {
bounds.include(x, y + stripe_y);
}
}
y += rows;
}
bounds.finish()
}
fn striped_luma_bounds(
img: &DynamicImage,
mut predicate: impl FnMut(u8) -> bool,
mut into_gray: impl FnMut(DynamicImage) -> GrayImage,
) -> Option<Rect> {
const STRIPE_ROWS: u32 = 64;
let (width, height) = img.dimensions();
let mut bounds = LumaBounds::new();
let mut y = 0;
while width != 0 && y < height {
let rows = (height - y).min(STRIPE_ROWS);
let gray = into_gray(img.crop_imm(0, y, width, rows));
for (x, stripe_y, pixel) in gray.enumerate_pixels() {
if predicate(pixel[0]) {
bounds.include(x, y + stripe_y);
}
}
y += rows;
}
bounds.finish()
}
fn luma_bounds_by(
pixels: impl Iterator<Item = (u32, u32, u8)>,
mut predicate: impl FnMut(u8) -> bool,
) -> Option<Rect> {
let mut bounds = LumaBounds::new();
for (x, y, luma) in pixels {
if predicate(luma) {
bounds.include(x, y);
}
}
bounds.finish()
}
struct LumaBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl LumaBounds {
fn new() -> Self {
Self {
x0: u32::MAX,
y0: u32::MAX,
x1: 0,
y1: 0,
}
}
fn include(&mut self, x: u32, y: u32) {
self.x0 = self.x0.min(x);
self.y0 = self.y0.min(y);
self.x1 = self.x1.max(x);
self.y1 = self.y1.max(y);
}
fn finish(self) -> Option<Rect> {
(self.x0 != u32::MAX).then(|| Rect {
x: self.x0,
y: self.y0,
w: self.x1 - self.x0 + 1,
h: self.y1 - self.y0 + 1,
})
}
}
pub fn ink_bounds(gray: &GrayImage, threshold: u8) -> Option<Rect> {
luma_bounds_by(
gray.enumerate_pixels()
.map(|(x, y, pixel)| (x, y, pixel[0])),
|luma| luma <= threshold,
)
}
pub fn fit_width(img: DynamicImage, max_width: u32) -> DynamicImage {
let (w, h) = img.dimensions();
if w <= max_width {
return img;
}
let new_h = ((h as f64) * (max_width as f64) / (w as f64)).round() as u32;
match img {
DynamicImage::ImageLuma8(gray) => DynamicImage::ImageLuma8(imageops::resize(
&gray,
max_width,
new_h.max(1),
imageops::FilterType::Triangle,
)),
image => DynamicImage::ImageRgba8(imageops::resize(
&image,
max_width,
new_h.max(1),
imageops::FilterType::Triangle,
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ContentBox {
canvas_w: u32,
canvas_h: u32,
origin_x: u32,
origin_y: u32,
margin: u32,
width: u32,
height: u32,
}
impl ContentBox {
fn for_label(label: LabelPx, safe: SafeArea, margin: u32) -> Result<Self> {
let area = safe.content(label).ok_or_else(|| {
Error::invalid_label(format!(
"safe area (top {}, bottom {}, left {}, right {}) leaves no content on a {}x{}px label",
safe.top,
safe.bottom,
safe.left,
safe.right,
label.width_px,
label.height_px
))
})?;
Ok(Self::in_rect(label, area, margin))
}
fn in_rect(label: LabelPx, area: Rect, margin: u32) -> Self {
let canvas_w = label.width_px.max(1);
let canvas_h = label.height_px.max(1);
let aw = area.w.max(1);
let ah = area.h.max(1);
let margin = margin.min(aw / 4).min(ah / 4);
Self {
canvas_w,
canvas_h,
origin_x: area.x + margin,
origin_y: area.y + margin,
margin,
width: aw.saturating_sub(margin * 2).max(1),
height: ah.saturating_sub(margin * 2).max(1),
}
}
fn white_canvas(&self) -> RgbaImage {
RgbaImage::from_pixel(
self.canvas_w,
self.canvas_h,
image::Rgba([255, 255, 255, 255]),
)
}
}
fn scaled_dimensions(img: &DynamicImage, scale: f64) -> (u32, u32) {
let (iw, ih) = img.dimensions();
(
((iw as f64) * scale).round().max(1.0) as u32,
((ih as f64) * scale).round().max(1.0) as u32,
)
}
fn cover_crop(iw: u32, ih: u32, target_w: u32, target_h: u32) -> Rect {
let center_aligned = |source: u32, crop: u32| {
if crop < source && (source - crop) % 2 == 1 {
crop + 1
} else {
crop
}
};
let source_cross = u64::from(iw) * u64::from(target_h);
let target_cross = u64::from(target_w) * u64::from(ih);
if source_cross > target_cross {
let numerator = u64::from(ih) * u64::from(target_w);
let crop_w = center_aligned(
iw,
numerator
.div_ceil(u64::from(target_h))
.clamp(1, u64::from(iw)) as u32,
);
Rect {
x: (iw - crop_w) / 2,
y: 0,
w: crop_w,
h: ih,
}
} else if source_cross < target_cross {
let numerator = u64::from(iw) * u64::from(target_h);
let crop_h = center_aligned(
ih,
numerator
.div_ceil(u64::from(target_w))
.clamp(1, u64::from(ih)) as u32,
);
Rect {
x: 0,
y: (ih - crop_h) / 2,
w: iw,
h: crop_h,
}
} else {
Rect {
x: 0,
y: 0,
w: iw,
h: ih,
}
}
}
pub fn fill_label(
img: DynamicImage,
label: LabelPx,
safe: SafeArea,
margin: u32,
) -> Result<DynamicImage> {
let bx = ContentBox::for_label(label, safe, margin)?;
let (iw, ih) = img.dimensions();
let crop = cover_crop(iw, ih, bx.width, bx.height);
let source = imageops::crop_imm(&img, crop.x, crop.y, crop.w, crop.h);
let scale = f64::max(
bx.width as f64 / crop.w as f64,
bx.height as f64 / crop.h as f64,
);
let nw = ((crop.w as f64) * scale).round().max(1.0) as u32;
let nh = ((crop.h as f64) * scale).round().max(1.0) as u32;
let resized = imageops::resize(
&*source,
nw.max(bx.width),
nh.max(bx.height),
imageops::FilterType::CatmullRom,
);
let visible = imageops::crop_imm(
&resized,
resized.width().saturating_sub(bx.width) / 2,
resized.height().saturating_sub(bx.height) / 2,
bx.width,
bx.height,
);
let mut canvas = bx.white_canvas();
imageops::overlay(
&mut canvas,
&*visible,
bx.origin_x as i64,
bx.origin_y as i64,
);
Ok(DynamicImage::ImageRgba8(canvas))
}
pub fn contain_label(
img: DynamicImage,
label: LabelPx,
safe: SafeArea,
margin: u32,
) -> Result<DynamicImage> {
let bx = ContentBox::for_label(label, safe, margin)?;
let (iw, ih) = img.dimensions();
let scale = f64::min(bx.width as f64 / iw as f64, bx.height as f64 / ih as f64);
let (nw, nh) = scaled_dimensions(&img, scale);
let resized = imageops::resize(&img, nw, nh, imageops::FilterType::CatmullRom);
let mut canvas = bx.white_canvas();
imageops::overlay(
&mut canvas,
&resized,
(bx.origin_x + bx.width.saturating_sub(nw) / 2) as i64,
(bx.origin_y + bx.height.saturating_sub(nh) / 2) as i64,
);
Ok(DynamicImage::ImageRgba8(canvas))
}
pub const CALIBRATION_RING_STEP_PX: u32 = 4;
pub const CALIBRATION_RINGS: u32 = 6;
pub const CALIBRATION_RULER_MAJOR_PX: u32 = 26;
pub const CALIBRATION_RULER_MINOR_PX: u32 = 12;
pub fn calibration_pattern(
label: LabelPx,
safe: Option<SafeArea>,
pixels_per_mm: f64,
) -> GrayImage {
let w = label.width_px;
let h = label.height_px;
let mut img = GrayImage::from_pixel(w, h, Luma([255]));
if w == 0 || h == 0 {
return img;
}
for y in 0..h {
for x in 0..w {
let expect_down = (y as i64 * (w as i64 - 1)) / (h as i64 - 1).max(1);
let expect_up = ((h as i64 - 1 - y as i64) * (w as i64 - 1)) / (h as i64 - 1).max(1);
let on_diag = (x as i64 - expect_down).abs() <= 1 || (x as i64 - expect_up).abs() <= 1;
let on_cross =
(x as i64 - w as i64 / 2).abs() <= 1 || (y as i64 - h as i64 / 2).abs() <= 1;
if on_diag || on_cross {
img.put_pixel(x, y, Luma([0]));
}
}
}
let ring_step = (0.5 * pixels_per_mm).round().max(1.0) as u32;
for ring in 0..CALIBRATION_RINGS {
let inset = ring * ring_step;
if inset * 2 + 1 >= w.min(h) {
break;
}
let (x0, y0) = (inset, inset);
let (x1, y1) = (w - 1 - inset, h - 1 - inset);
for x in x0..=x1 {
img.put_pixel(x, y0, Luma([0]));
img.put_pixel(x, y1, Luma([0]));
}
for y in y0..=y1 {
img.put_pixel(x0, y, Luma([0]));
img.put_pixel(x1, y, Luma([0]));
}
}
let ruler_scale = pixels_per_mm / crate::geometry::PX_PER_MM;
let major_len = (f64::from(CALIBRATION_RULER_MAJOR_PX) * ruler_scale).round() as u32;
let minor_len = (f64::from(CALIBRATION_RULER_MINOR_PX) * ruler_scale).round() as u32;
let height_mm = (f64::from(h) / pixels_per_mm).floor() as u32;
for mm in 0..=height_mm {
let y = (f64::from(mm) * pixels_per_mm).round() as u32;
if y >= h {
break;
}
let major = mm % 5 == 0;
let len = if major { major_len } else { minor_len };
let thick = if major { 3 } else { 1 };
for t in 0..thick {
let yy = (y + t).min(h - 1);
for x in 0..len.min(w) {
img.put_pixel(x, yy, Luma([0]));
img.put_pixel(w - 1 - x, yy, Luma([0]));
}
}
}
if let Some(area) = safe.and_then(|s| s.content(label)) {
let t = 3i64;
let (x0, y0) = (area.x as i64, area.y as i64);
let (x1, y1) = (x0 + area.w as i64 - 1, y0 + area.h as i64 - 1);
for y in 0..h as i64 {
for x in 0..w as i64 {
let inside = x >= x0 && x <= x1 && y >= y0 && y <= y1;
let near_edge = (x - x0).abs() < t
|| (x - x1).abs() < t
|| (y - y0).abs() < t
|| (y - y1).abs() < t;
if inside && near_edge {
img.put_pixel(x as u32, y as u32, Luma([0]));
}
}
}
}
img
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::LabelMm;
use crate::protocol::Cmd;
#[test]
fn encode_respects_max_width() {
let img = DynamicImage::ImageLuma8(GrayImage::from_pixel(100, 50, Luma([0])));
let r = encode(img, 384, 127, false).unwrap();
let (w, h, pkts) = (r.width, r.height, r.rows);
assert_eq!((w, h), (100, 50));
assert_eq!(pkts.len(), 1);
assert_eq!(pkts[0].data[5], 50);
}
#[test]
fn encode_rejects_too_wide() {
let img = DynamicImage::ImageLuma8(GrayImage::from_pixel(400, 10, Luma([0])));
assert!(encode(img, 384, 127, false).is_err());
}
#[test]
fn borrowed_and_owned_gray_encoding_match() {
let gray = GrayImage::from_fn(17, 13, |x, y| Luma([((x * 37 + y * 71) & 0xff) as u8]));
for dither in [false, true] {
let borrowed = encode_gray(&gray, 384, 113, dither).unwrap();
let owned = encode(DynamicImage::ImageLuma8(gray.clone()), 384, 113, dither).unwrap();
assert_eq!(borrowed, owned);
}
}
#[test]
fn fit_width_preserves_grayscale_storage() {
let image = DynamicImage::ImageLuma8(GrayImage::from_fn(800, 200, |x, y| {
Luma([((x * 19 + y * 43) & 0xff) as u8])
}));
let expected = imageops::resize(&image, 400, 100, imageops::FilterType::Triangle);
let resized = fit_width(image, 400);
assert_eq!(resized.dimensions(), (400, 100));
assert_eq!(resized.to_rgba8(), expected);
assert!(
matches!(resized, DynamicImage::ImageLuma8(_)),
"grayscale input should not expand to four-byte RGBA pixels"
);
}
#[test]
fn fit_width_preserves_legacy_high_precision_conversion() {
let image = DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(800, 2, |x, _| {
image::Luma([((x * 83) & 0xffff) as u16])
}));
let expected = DynamicImage::ImageRgba8(imageops::resize(
&image,
400,
1,
imageops::FilterType::Triangle,
));
assert_eq!(fit_width(image, 400), expected);
}
#[test]
fn fit_width_preserves_legacy_alpha_resampling() {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(800, 4, |x, y| {
image::Rgba([
((x * 7) & 0xff) as u8,
((y * 61) & 0xff) as u8,
((x + y * 13) & 0xff) as u8,
((x * 29 + y) & 0xff) as u8,
])
}));
let expected = DynamicImage::ImageRgba8(imageops::resize(
&image,
400,
2,
imageops::FilterType::Triangle,
));
assert_eq!(fit_width(image, 400), expected);
}
#[test]
fn transparent_black_is_white_for_encoding_and_trimming() {
let transparent =
DynamicImage::ImageRgba8(RgbaImage::from_pixel(8, 2, image::Rgba([0, 0, 0, 0])));
let gray = grayscale_on_white(transparent.clone());
assert!(gray.pixels().all(|pixel| pixel[0] == 255));
let raster = encode(transparent, 384, 127, false).unwrap();
assert_eq!(raster.rows.len(), 1);
assert_eq!(raster.rows[0].cmd, Cmd::PrintEmptyRow as u8);
assert_eq!(raster.rows[0].data[2], 2);
let mut rgba = RgbaImage::from_pixel(7, 5, image::Rgba([0, 0, 0, 0]));
rgba.put_pixel(3, 2, image::Rgba([0, 0, 0, 255]));
let trimmed = trim_white(DynamicImage::ImageRgba8(rgba), 127);
assert_eq!(trimmed.dimensions(), (1, 1));
assert_eq!(trimmed.get_pixel(0, 0), image::Rgba([0, 0, 0, 255]));
}
#[test]
fn semi_transparent_black_uses_the_same_threshold_for_encode_and_trim() {
let mut rgba = RgbaImage::from_pixel(8, 1, image::Rgba([0, 0, 0, 0]));
rgba.put_pixel(1, 0, image::Rgba([0, 0, 0, 127]));
rgba.put_pixel(2, 0, image::Rgba([0, 0, 0, 128]));
let image = DynamicImage::ImageRgba8(rgba);
let raster = encode(image.clone(), 384, 127, false).unwrap();
assert_eq!(raster.rows.len(), 1);
assert_eq!(raster.rows[0].cmd, Cmd::PrintBitmapRow as u8);
assert_eq!(raster.rows[0].data[6], 0b0010_0000);
let trimmed = trim_white(image, 127);
assert_eq!(trimmed.dimensions(), (1, 1));
assert_eq!(trimmed.get_pixel(0, 0), image::Rgba([0, 0, 0, 128]));
}
#[test]
fn opaque_rgba_keeps_the_image_crate_luminance_at_the_burn_boundary() {
let mut rgba = RgbaImage::from_pixel(3, 1, image::Rgba([255, 255, 255, 255]));
rgba.put_pixel(1, 0, image::Rgba([0, 153, 251, 255]));
let image = DynamicImage::ImageRgba8(rgba);
let expected = image.clone().into_luma8();
assert_eq!(expected.as_raw(), &[255, 128, 255]);
assert_eq!(grayscale_on_white(image.clone()), expected);
assert_eq!(trim_for_print(image, 127, false).dimensions(), (3, 1));
}
#[test]
fn print_trim_uses_darkness_threshold_without_changing_public_trim_semantics() {
let gray = GrayImage::from_raw(7, 1, vec![255, 204, 205, 0, 205, 204, 255]).unwrap();
let image = DynamicImage::ImageLuma8(gray);
let public = trim_white(image.clone(), 50).into_luma8();
assert_eq!(public.dimensions(), (1, 1));
assert_eq!(public.as_raw(), &[0]);
let print = trim_for_print(image, 50, false).into_luma8();
assert_eq!(print.dimensions(), (5, 1));
assert_eq!(print.as_raw(), &[204, 205, 0, 205, 204]);
assert_eq!(
render_print_preview(&print, 384, 50, false)
.unwrap()
.as_raw(),
&[0, 255, 0, 255, 0]
);
}
#[test]
fn print_trim_composites_alpha_before_applying_a_non_default_threshold() {
let mut rgba = RgbaImage::from_pixel(7, 70, image::Rgba([0, 0, 0, 0]));
rgba.put_pixel(1, 65, image::Rgba([0, 0, 0, 50]));
rgba.put_pixel(2, 65, image::Rgba([0, 0, 0, 51]));
rgba.put_pixel(4, 65, image::Rgba([0, 0, 0, 51]));
rgba.put_pixel(5, 65, image::Rgba([0, 0, 0, 50]));
let trimmed = trim_for_print(DynamicImage::ImageRgba8(rgba), 50, false);
assert_eq!(trimmed.dimensions(), (3, 1));
assert_eq!(grayscale_on_white(trimmed).as_raw(), &[204, 255, 204]);
}
#[test]
fn dither_trim_keeps_full_width_and_preserves_retained_burn_bits() {
let content = GrayImage::from_raw(
6,
2,
vec![255, 255, 32, 32, 255, 255, 255, 255, 192, 128, 255, 255],
)
.unwrap();
let expected = render_print_preview(&content, 384, 128, true).unwrap();
let horizontally_cropped = imageops::crop_imm(&content, 2, 0, 2, 2).to_image();
let cropped_bits = render_print_preview(&horizontally_cropped, 384, 128, true).unwrap();
let expected_content_bits = imageops::crop_imm(&expected, 2, 0, 2, 2).to_image();
assert_ne!(cropped_bits, expected_content_bits);
let mut padded = GrayImage::from_pixel(6, 4, Luma([255]));
imageops::replace(&mut padded, &content, 0, 1);
let padded_bits = render_print_preview(&padded, 384, 128, true).unwrap();
let expected_retained_bits = imageops::crop_imm(&padded_bits, 0, 1, 6, 2).to_image();
let trimmed = trim_for_print(DynamicImage::ImageLuma8(padded), 128, true).into_luma8();
assert_eq!(trimmed, content);
assert_eq!(trimmed.dimensions(), (6, 2));
assert_eq!(
render_print_preview(&trimmed, 384, 128, true).unwrap(),
expected_retained_bits
);
}
#[test]
fn encode_rejects_an_over_tall_page_before_row_indices_wrap() {
let gray = GrayImage::from_pixel(1, u32::from(u16::MAX) + 1, Luma([255]));
assert!(matches!(
encode_gray(&gray, 384, 127, false),
Err(Error::ImageTooLarge {
width: 1,
height: 65_536
})
));
}
#[test]
fn encode_rejects_rows_too_wide_for_the_packet_length_byte() {
let width = MAX_BITMAP_WIDTH_PX + 1;
let gray = GrayImage::from_pixel(width, 1, Luma([0]));
assert!(matches!(
encode_gray(&gray, width, 127, false),
Err(Error::ImageTooLarge { width: w, height: 1 }) if w == width
));
}
#[test]
fn raster_constructor_rejects_malformed_row_payloads() {
let missing_repeat = Packet::new(protocol::Cmd::PrintEmptyRow as u8, [0, 0]);
assert!(matches!(
Raster::try_new(8, 1, vec![missing_repeat]),
Err(Error::InvalidRaster(_))
));
let wrong_bitmap_width = protocol::print_bitmap_row(0, 1, &[0, 0]);
assert!(matches!(
Raster::try_new(8, 1, vec![wrong_bitmap_width]),
Err(Error::InvalidRaster(_))
));
let repeated = protocol::print_empty_row(0, 2);
assert!(matches!(
Raster::try_new(8, 1, vec![repeated]),
Err(Error::InvalidRaster(_))
));
let gap = protocol::print_empty_row(1, 1);
assert!(matches!(
Raster::try_new(8, 1, vec![gap]),
Err(Error::InvalidRaster(_))
));
let oversized = protocol::print_bitmap_row(0, 1, &[0; MAX_DATA_LEN - 5]);
assert!(matches!(
Raster::try_new(MAX_BITMAP_WIDTH_PX + 1, 1, vec![oversized]),
Err(Error::InvalidRaster(_))
));
}
#[test]
fn repeat_coalescing_splits_runs_at_255() {
let blank = DynamicImage::ImageLuma8(GrayImage::from_pixel(8, 640, Luma([255])));
let raster = encode(blank, 384, 127, false).unwrap();
assert_eq!(raster.rows.len(), 3);
let starts_and_repeats: Vec<_> = raster
.rows
.iter()
.map(|packet| {
(
u16::from_be_bytes([packet.data[0], packet.data[1]]),
packet.data[2],
)
})
.collect();
assert_eq!(starts_and_repeats, [(0, 255), (255, 255), (510, 130)]);
raster.validate().unwrap();
}
#[test]
fn only_consecutive_identical_rows_are_coalesced() {
let mut image = GrayImage::from_pixel(8, 4, Luma([255]));
for x in 0..8 {
image.put_pixel(x, 1, Luma([0]));
image.put_pixel(x, 3, Luma([0]));
}
let raster = encode(DynamicImage::ImageLuma8(image), 384, 127, false).unwrap();
assert_eq!(raster.rows.len(), 4);
assert!(raster.rows.iter().all(|packet| match packet.cmd {
cmd if cmd == Cmd::PrintEmptyRow as u8 => packet.data[2] == 1,
cmd if cmd == Cmd::PrintBitmapRow as u8 => packet.data[5] == 1,
_ => false,
}));
}
#[test]
fn fused_encoder_matches_reference_packet_packing() {
fn reference_packets(gray: &GrayImage, threshold: u8, dither: bool) -> Vec<Packet> {
let bits = gray_to_print_bits(gray, threshold, dither);
let (width, height) = bits.dimensions();
let mut packets = Vec::new();
let mut run_start = 0u16;
let mut run_pixels: Option<Vec<u8>> = None;
let mut run_len = 0u8;
for y in 0..height {
let mut row = vec![0u8; (width as usize).div_ceil(8)];
for x in 0..width {
if bits.get_pixel(x, y)[0] > 127 {
row[(x / 8) as usize] |= 1 << (7 - (x % 8));
}
}
let pixels = row.iter().any(|&byte| byte != 0).then_some(row);
if run_len > 0 && run_pixels == pixels && run_len < u8::MAX {
run_len += 1;
continue;
}
if run_len > 0 {
push_row_run(&mut packets, run_start, run_len, run_pixels.as_deref());
}
run_start = y as u16;
run_pixels = pixels;
run_len = 1;
}
if run_len > 0 {
push_row_run(&mut packets, run_start, run_len, run_pixels.as_deref());
}
packets
}
for width in [1, 7, 8, 9, 31, 384] {
let gray = GrayImage::from_fn(width, 17, |x, y| {
Luma([((x * 37) ^ (y * 73) ^ (x * y)) as u8])
});
for threshold in [0, 127, 255] {
for dither in [false, true] {
assert_eq!(
encode_gray(&gray, 384, threshold, dither).unwrap().rows,
reference_packets(&gray, threshold, dither),
"width={width}, threshold={threshold}, dither={dither}"
);
}
}
}
}
#[test]
fn fill_label_exact_size() {
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(50, 50, Luma([0])));
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let out = fill_label(src, lp, SafeArea::NONE, 0).unwrap();
assert_eq!(out.dimensions(), (lp.width_px, lp.height_px));
}
#[test]
fn label_placement_rejects_invalid_label_and_safe_area_combinations() {
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(1, 1, Luma([0])));
let invalid = [
(
LabelPx {
width_px: 10,
height_px: 8,
},
SafeArea {
top: 0,
bottom: 0,
left: 5,
right: 5,
},
),
(
LabelPx {
width_px: 0,
height_px: 8,
},
SafeArea::NONE,
),
];
for (label, safe) in invalid {
for result in [
fill_label(src.clone(), label, safe, 0),
contain_label(src.clone(), label, safe, 0),
] {
assert!(
matches!(result, Err(Error::InvalidLabel(ref message)) if message.contains("leaves no content")),
"unexpected placement result: {result:?}"
);
}
}
}
#[test]
fn fill_label_centers_cover_crop_inside_safe_area_and_margin() {
let mut source = GrayImage::from_pixel(12, 4, Luma([255]));
for y in 0..4 {
for x in 4..8 {
source.put_pixel(x, y, Luma([0]));
}
}
let label = LabelPx {
width_px: 10,
height_px: 8,
};
let safe = SafeArea {
top: 1,
bottom: 1,
left: 2,
right: 2,
};
let output = fill_label(DynamicImage::ImageLuma8(source), label, safe, 1)
.unwrap()
.to_luma8();
assert_eq!(output.dimensions(), (10, 8));
assert_eq!(
ink_bounds(&output, 127),
Some(Rect {
x: 3,
y: 2,
w: 4,
h: 4,
})
);
}
#[test]
fn fill_label_handles_an_extreme_aspect_ratio() {
let mut source = GrayImage::from_pixel(1, 100_000, Luma([255]));
source.put_pixel(0, 49_999, Luma([0]));
source.put_pixel(0, 50_000, Luma([0]));
let label = LabelPx {
width_px: 64,
height_px: 32,
};
let output = fill_label(DynamicImage::ImageLuma8(source), label, SafeArea::NONE, 0)
.unwrap()
.to_luma8();
assert_eq!(output.dimensions(), (64, 32));
assert!(output.pixels().all(|pixel| pixel[0] == 0));
}
#[test]
fn fill_label_keeps_one_scale_factor_for_tiny_sources() {
let mut source = GrayImage::from_pixel(2, 3, Luma([255]));
for x in 0..2 {
source.put_pixel(x, 1, Luma([0]));
}
let label = LabelPx {
width_px: 384,
height_px: 240,
};
let output = fill_label(DynamicImage::ImageLuma8(source), label, SafeArea::NONE, 0)
.unwrap()
.to_luma8();
let (darkest, lightest) = output.pixels().fold((u8::MAX, u8::MIN), |(lo, hi), pixel| {
(lo.min(pixel[0]), hi.max(pixel[0]))
});
assert!(
darkest < lightest,
"cover resize flattened all source detail"
);
for x in 0..label.width_px {
assert_eq!(
output.get_pixel(x, 0),
output.get_pixel(x, label.height_px - 1),
"integer source crop shifted away from the image center"
);
}
}
#[test]
fn contain_label_centers_with_white_margins() {
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(40, 80, Luma([0])));
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let out = contain_label(src, lp, SafeArea::NONE, 0)
.unwrap()
.to_luma8();
assert_eq!(out.dimensions(), (lp.width_px, lp.height_px));
assert_eq!(out.get_pixel(0, 0)[0], 255);
assert_eq!(out.get_pixel(lp.width_px - 1, 0)[0], 255);
let cx = lp.width_px / 2;
let cy = lp.height_px / 2;
assert_eq!(out.get_pixel(cx, cy)[0], 0);
}
#[test]
fn contain_label_respects_margin() {
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(200, 200, Luma([0])));
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let margin = 16u32;
let out = contain_label(src, lp, SafeArea::NONE, margin)
.unwrap()
.to_luma8();
for x in 0..lp.width_px {
assert_eq!(out.get_pixel(x, 0)[0], 255);
assert_eq!(out.get_pixel(x, margin - 1)[0], 255);
}
}
#[test]
fn raw_images_are_kept_out_of_the_unprintable_band() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let safe = SafeArea::B1;
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(100, 100, Luma([0])));
for placed in [
fill_label(src.clone(), lp, safe, 0).unwrap(),
contain_label(src, lp, safe, 0).unwrap(),
] {
let g = placed.to_luma8();
assert_eq!(g.dimensions(), (lp.width_px, lp.height_px));
for y in (lp.height_px - safe.bottom)..lp.height_px {
for x in 0..lp.width_px {
assert_eq!(g.get_pixel(x, y)[0], 255, "ink at ({x},{y}) is unprintable");
}
}
for y in 0..safe.top {
for x in 0..lp.width_px {
assert_eq!(g.get_pixel(x, y)[0], 255, "ink at ({x},{y}) is unprintable");
}
}
}
}
#[test]
fn trim_removes_the_artwork_s_own_margin() {
let mut g = GrayImage::from_pixel(100, 100, Luma([255]));
for y in 40..60 {
for x in 40..60 {
g.put_pixel(x, y, Luma([0]));
}
}
let out = trim_white(DynamicImage::ImageLuma8(g), 127);
assert_eq!(out.dimensions(), (20, 20));
}
fn trim_from_reference_gray(
source: &DynamicImage,
gray: &GrayImage,
predicate: impl FnMut(u8) -> bool,
) -> DynamicImage {
let bounds = luma_bounds_by(
gray.enumerate_pixels()
.map(|(x, y, pixel)| (x, y, pixel[0])),
predicate,
);
crop_to_bounds(source.clone(), bounds)
}
#[test]
fn striped_rgb8_trim_matches_full_conversion_at_every_threshold() {
let mut rgb = image::RgbImage::from_fn(23, 139, |x, y| {
if !(2..21).contains(&x) || !(3..136).contains(&y) {
image::Rgb([255, 255, 255])
} else {
image::Rgb([
((x * 17 + y * 3) & 0xff) as u8,
((x * 5 + y * 29) & 0xff) as u8,
((x * 11 + y * 7) & 0xff) as u8,
])
}
});
rgb.set_color_space(image::metadata::Cicp::DISPLAY_P3)
.unwrap();
let image = DynamicImage::ImageRgb8(rgb);
let full_page_gray = image.to_luma8();
let mut srgb = image.as_rgb8().unwrap().clone();
srgb.set_color_space(image::metadata::Cicp::SRGB).unwrap();
assert_ne!(full_page_gray, DynamicImage::ImageRgb8(srgb).to_luma8());
for threshold in 0..=u8::MAX {
let expected =
trim_from_reference_gray(&image, &full_page_gray, |luma| luma <= threshold);
assert_eq!(
trim_white(image.clone(), threshold),
expected,
"public trim differs at threshold {threshold}"
);
let expected = trim_from_reference_gray(&image, &full_page_gray, |luma| {
burns_at_threshold(luma, threshold)
});
assert_eq!(
trim_for_print(image.clone(), threshold, false),
expected,
"print trim differs at threshold {threshold}"
);
}
}
#[test]
fn high_precision_and_alpha_trim_still_match_full_conversion() {
let rgb16 = image::ImageBuffer::<image::Rgb<u16>, Vec<u16>>::from_fn(17, 73, |x, y| {
image::Rgb([
((x * 4_099 + y * 307) & 0xffff) as u16,
((x * 977 + y * 8_191) & 0xffff) as u16,
((x * 65_521 + y * 31) & 0xffff) as u16,
])
});
let high_precision = DynamicImage::ImageRgb16(rgb16);
let high_precision_gray = high_precision.to_luma8();
let rgba = RgbaImage::from_fn(19, 137, |x, y| {
image::Rgba([
((x * 47 + y * 13) & 0xff) as u8,
((x * 7 + y * 53) & 0xff) as u8,
((x * 31 + y * 19) & 0xff) as u8,
((x * 61 + y * 23) & 0xff) as u8,
])
});
let alpha = DynamicImage::ImageRgba8(rgba);
let alpha_gray = grayscale_on_white(alpha.clone());
for threshold in 0..=u8::MAX {
assert_eq!(
trim_white(high_precision.clone(), threshold),
trim_from_reference_gray(&high_precision, &high_precision_gray, |luma| {
luma <= threshold
}),
"RGB16 trim differs at threshold {threshold}"
);
assert_eq!(
trim_white(alpha.clone(), threshold),
trim_from_reference_gray(&alpha, &alpha_gray, |luma| luma <= threshold),
"RGBA8 trim differs at threshold {threshold}"
);
}
}
#[test]
fn trim_preserves_the_source_pixel_format() {
let mut rgb = image::RgbImage::from_pixel(8, 6, image::Rgb([255, 255, 255]));
for y in 2..4 {
for x in 3..6 {
rgb.put_pixel(x, y, image::Rgb([0, 0, 0]));
}
}
let output = trim_white(DynamicImage::ImageRgb8(rgb), 127);
assert_eq!(output.dimensions(), (3, 2));
let DynamicImage::ImageRgb8(cropped) = output else {
panic!("trimming converted an RGB source to another pixel format");
};
assert!(cropped.pixels().all(|pixel| pixel.0 == [0, 0, 0]));
}
#[test]
fn trim_keeps_high_precision_luminance_at_threshold_boundaries() {
let mut rgb = image::ImageBuffer::<image::Rgb<u16>, Vec<u16>>::from_pixel(
3,
1,
image::Rgb([u16::MAX; 3]),
);
rgb.put_pixel(1, 0, image::Rgb([10_103, 24_170, 64_193]));
let output = trim_white(DynamicImage::ImageRgb16(rgb), 93);
assert_eq!(output.dimensions(), (3, 1));
assert!(matches!(output, DynamicImage::ImageRgb16(_)));
}
#[test]
fn trim_leaves_blank_and_already_tight_images_alone() {
let blank = DynamicImage::ImageLuma8(GrayImage::from_pixel(40, 20, Luma([255])));
assert_eq!(trim_white(blank, 127).dimensions(), (40, 20));
let full = DynamicImage::ImageLuma8(GrayImage::from_pixel(40, 20, Luma([0])));
assert_eq!(trim_white(full, 127).dimensions(), (40, 20));
}
#[test]
fn trimmed_art_fills_the_printable_band() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let safe = SafeArea::B1;
let mut g = GrayImage::from_pixel(384, 240, Luma([255]));
for y in 60..180 {
for x in 90..300 {
g.put_pixel(x, y, Luma([0]));
}
}
let art = trim_white(DynamicImage::ImageLuma8(g), 127);
let placed = contain_label(art, lp, safe, 0).unwrap().to_luma8();
let usable = lp.height_px - safe.bottom;
let mut lowest = 0;
for (_, y, p) in placed.enumerate_pixels() {
if p[0] < 128 {
lowest = lowest.max(y);
}
}
assert!(lowest < usable, "ink at {lowest} is unprintable");
assert!(
lowest + 8 >= usable,
"only reached row {lowest} of a {usable}-row band — not filling it"
);
}
#[test]
fn safe_area_none_still_fills_the_whole_canvas() {
let lp = LabelMm::parse("50x30").unwrap().to_pixels(384, 8.0);
let src = DynamicImage::ImageLuma8(GrayImage::from_pixel(100, 100, Luma([0])));
let g = fill_label(src, lp, SafeArea::NONE, 0).unwrap().to_luma8();
assert_eq!(g.get_pixel(0, 0)[0], 0);
assert_eq!(g.get_pixel(lp.width_px - 1, lp.height_px - 1)[0], 0);
}
#[test]
fn dither_produces_mixed_dots_on_gray() {
let g = GrayImage::from_pixel(32, 32, Luma([128]));
let hard = gray_to_print_bits(&g, 127, false);
let dit = gray_to_print_bits(&g, 127, true);
let hard_black = hard.pixels().filter(|p| p[0] > 127).count();
let dit_black = dit.pixels().filter(|p| p[0] > 127).count();
assert_eq!(hard_black, 0);
assert!(dit_black > 50, "dither black count {dit_black}");
assert!(dit_black < 32 * 32 - 50, "dither not solid");
}
#[test]
fn rolling_dither_matches_the_full_page_algorithm() {
fn full_page_dither(gray: &GrayImage, threshold: u8) -> GrayImage {
let (w, h) = gray.dimensions();
let mut error = gray
.pixels()
.map(|pixel| f32::from(255u8.saturating_sub(pixel[0])))
.collect::<Vec<_>>();
let mut output = GrayImage::new(w, h);
let threshold = f32::from(threshold);
for y in 0..h {
for x in 0..w {
let index = (y * w + x) as usize;
let old = error[index];
let new = if old > threshold { 255.0 } else { 0.0 };
let delta = old - new;
output.put_pixel(x, y, Luma([new as u8]));
if x + 1 < w {
error[index + 1] += delta * (7.0 / 16.0);
}
if y + 1 < h {
let next_row = index + w as usize;
if x > 0 {
error[next_row - 1] += delta * (3.0 / 16.0);
}
error[next_row] += delta * (5.0 / 16.0);
if x + 1 < w {
error[next_row + 1] += delta * (1.0 / 16.0);
}
}
}
}
output
}
let gray = GrayImage::from_fn(31, 19, |x, y| Luma([((x * 29) ^ (y * 83) ^ (x * y)) as u8]));
for threshold in [0, 63, 127, 191, 255] {
assert_eq!(
gray_to_print_bits(&gray, threshold, true),
full_page_dither(&gray, threshold)
);
}
}
#[test]
fn empty_gray_conversion_stays_empty() {
assert!(gray_to_print_bits(&GrayImage::new(0, 0), 127, false).is_empty());
assert!(gray_to_print_bits(&GrayImage::new(0, 0), 127, true).is_empty());
}
#[test]
fn dark_source_pixels_become_bitmap_rows() {
let dark = DynamicImage::ImageLuma8(GrayImage::from_pixel(16, 2, Luma([0])));
let rows = encode(dark, 384, 127, false).unwrap().rows;
assert!(rows.iter().all(|p| p.cmd == Cmd::PrintBitmapRow as u8));
}
#[test]
fn white_source_pixels_become_empty_rows() {
let light = DynamicImage::ImageLuma8(GrayImage::from_pixel(16, 2, Luma([255])));
let rows = encode(light, 384, 127, false).unwrap().rows;
assert!(rows.iter().all(|p| p.cmd == Cmd::PrintEmptyRow as u8));
}
}