Lamp testbench in Rust around the HPCS 6500

lampbench station / export / label / tm30 / calibrate replace the Python
lamp_station.py, lamp_export.py, label_printer.py, tm30.py and
calibration.py that lived in HPCS6500-py. The driver is a submodule
(HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of
colour-science 0.4.7), brother-ql (raster protocol, byte-identical to
brother_ql) and lamp-label (QR + ID labels for the QL-820NWB).

Bundles are byte-identical to lamp_export.py's (golden tests); the station
runs end to end in a test against the simulated instrument.
This commit is contained in:
grabowski committed 2026-10-22 17:26:14 +07:00
commit 2a3f28001c
241 files changed
+46390

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
[package]
name = "brother-ql"
description = "Brother QL label printer raster protocol (byte-identical port of the Python brother_ql library)"
version.workspace = true
edition.workspace = true
publish.workspace = true
[lib]
name = "brother_ql"
[dependencies]
[dev-dependencies]
png = "0.17"
+272
View File
@@ -0,0 +1,272 @@
//! A minimal 1-bit image type.
use std::fmt;
/// A 1-bit image. `true` means black (a printed dot), `false` means white.
///
/// Pixels are addressed as `(x, y)` with `(0, 0)` in the top-left corner,
/// `x` growing to the right and `y` growing downwards (the same convention as
/// PIL).
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Bitmap {
width: u32,
height: u32,
/// Row-major pixels, `true` = black.
pixels: Vec<bool>,
}
impl Bitmap {
/// A new all-white bitmap.
pub fn new(width: u32, height: u32) -> Self {
Self::filled(width, height, false)
}
/// A new bitmap with every pixel set to `black`.
pub fn filled(width: u32, height: u32, black: bool) -> Self {
let len = width as usize * height as usize;
Self {
width,
height,
pixels: vec![black; len],
}
}
/// Builds a bitmap by evaluating `f(x, y)` (returns `true` for black) for every pixel.
pub fn from_fn(width: u32, height: u32, mut f: impl FnMut(u32, u32) -> bool) -> Self {
let mut pixels = Vec::with_capacity(width as usize * height as usize);
for y in 0..height {
for x in 0..width {
pixels.push(f(x, y));
}
}
Self {
width,
height,
pixels,
}
}
/// Width in pixels.
pub fn width(&self) -> u32 {
self.width
}
/// Height in pixels.
pub fn height(&self) -> u32 {
self.height
}
/// `(width, height)`.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
fn index(&self, x: u32, y: u32) -> usize {
assert!(
x < self.width && y < self.height,
"pixel ({x}, {y}) out of bounds for a {}x{} bitmap",
self.width,
self.height
);
y as usize * self.width as usize + x as usize
}
/// Returns `true` if the pixel is black.
///
/// # Panics
/// If `(x, y)` is outside the bitmap.
pub fn get(&self, x: u32, y: u32) -> bool {
self.pixels[self.index(x, y)]
}
/// Sets a pixel (`true` = black).
///
/// # Panics
/// If `(x, y)` is outside the bitmap.
pub fn set(&mut self, x: u32, y: u32, black: bool) {
let i = self.index(x, y);
self.pixels[i] = black;
}
/// Sets a pixel given signed coordinates; pixels outside the bitmap are ignored.
pub fn set_clipped(&mut self, x: i64, y: i64, black: bool) {
if x >= 0 && y >= 0 && x < i64::from(self.width) && y < i64::from(self.height) {
self.set(x as u32, y as u32, black);
}
}
/// Fills the rectangle `[x, x + w) x [y, y + h)` (clipped to the bitmap).
pub fn fill_rect(&mut self, x: i64, y: i64, w: u32, h: u32, black: bool) {
let x0 = x.max(0);
let y0 = y.max(0);
let x1 = (x + i64::from(w)).min(i64::from(self.width));
let y1 = (y + i64::from(h)).min(i64::from(self.height));
for yy in y0..y1 {
for xx in x0..x1 {
self.set(xx as u32, yy as u32, black);
}
}
}
/// Copies `src` into this bitmap with its top-left corner at `(x, y)`,
/// overwriting both black and white pixels (like PIL `Image.paste` without
/// a mask). Parts falling outside are clipped.
pub fn paste(&mut self, src: &Bitmap, x: i64, y: i64) {
for sy in 0..src.height {
let dy = y + i64::from(sy);
if dy < 0 || dy >= i64::from(self.height) {
continue;
}
for sx in 0..src.width {
let dx = x + i64::from(sx);
if dx < 0 || dx >= i64::from(self.width) {
continue;
}
self.set(dx as u32, dy as u32, src.get(sx, sy));
}
}
}
/// Returns the sub-image `[x, x + w) x [y, y + h)`; pixels outside the
/// source are white.
pub fn crop(&self, x: i64, y: i64, w: u32, h: u32) -> Bitmap {
Bitmap::from_fn(w, h, |cx, cy| {
let sx = x + i64::from(cx);
let sy = y + i64::from(cy);
sx >= 0
&& sy >= 0
&& sx < i64::from(self.width)
&& sy < i64::from(self.height)
&& self.get(sx as u32, sy as u32)
})
}
/// Rotates 90 degrees counter-clockwise (PIL `Image.rotate(90, expand=True)`
/// / `Transpose.ROTATE_90`).
pub fn rotate90(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(h, w, |x, y| self.get(w - 1 - y, x))
}
/// Rotates 180 degrees.
pub fn rotate180(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(w, h, |x, y| self.get(w - 1 - x, h - 1 - y))
}
/// Rotates 270 degrees counter-clockwise (= 90 degrees clockwise, PIL
/// `Transpose.ROTATE_270`).
pub fn rotate270(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(h, w, |x, y| self.get(y, h - 1 - x))
}
/// Mirrors left-right (PIL `Transpose.FLIP_LEFT_RIGHT`).
pub fn flip_horizontal(&self) -> Bitmap {
let w = self.width;
Bitmap::from_fn(w, self.height, |x, y| self.get(w - 1 - x, y))
}
/// Nearest-neighbour resize exactly as Pillow does it for mode `"1"`
/// images (Pillow forces `NEAREST` for 1-bit images whatever filter is
/// requested): source coordinate = `floor(scale * 0.5 + i * scale)` with the
/// position accumulated in `f64` like `ImagingScaleAffine`.
pub fn resize_nearest(&self, width: u32, height: u32) -> Bitmap {
if width == self.width && height == self.height {
return self.clone();
}
let xmap = nearest_map(self.width, width);
let ymap = nearest_map(self.height, height);
Bitmap::from_fn(width, height, |x, y| {
match (xmap[x as usize], ymap[y as usize]) {
(Some(sx), Some(sy)) => self.get(sx, sy),
_ => false,
}
})
}
/// Number of black pixels.
pub fn count_black(&self) -> usize {
self.pixels.iter().filter(|&&p| p).count()
}
/// Row `y` as booleans (`true` = black).
pub fn row(&self, y: u32) -> &[bool] {
let start = self.index(0, y);
&self.pixels[start..start + self.width as usize]
}
/// All pixels in row-major order (`true` = black).
pub fn pixels(&self) -> &[bool] {
&self.pixels
}
}
fn nearest_map(src: u32, dst: u32) -> Vec<Option<u32>> {
let scale = f64::from(src) / f64::from(dst);
let mut pos = scale * 0.5;
let mut out = Vec::with_capacity(dst as usize);
for _ in 0..dst {
// COORD(v) in Pillow: v < 0 ? -1 : (int)v
let i = if pos < 0.0 { -1 } else { pos as i64 };
out.push(if i >= 0 && i < i64::from(src) {
Some(i as u32)
} else {
None
});
pos += scale;
}
out
}
impl fmt::Debug for Bitmap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Bitmap")
.field("width", &self.width)
.field("height", &self.height)
.field("black_pixels", &self.count_black())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotations_match_pil_direction() {
// single black pixel in the top-left corner of a 3x2 image
let mut b = Bitmap::new(3, 2);
b.set(0, 0, true);
// PIL rotate(90) is counter-clockwise: top-left goes to bottom-left
let r = b.rotate90();
assert_eq!(r.size(), (2, 3));
assert!(r.get(0, 2));
let r = b.rotate270();
assert!(r.get(1, 0));
let r = b.rotate180();
assert!(r.get(2, 1));
assert_eq!(b.rotate90().rotate90(), b.rotate180());
assert_eq!(b.rotate90().rotate270(), b);
assert!(b.flip_horizontal().get(2, 0));
}
#[test]
fn paste_and_crop_clip() {
let mut b = Bitmap::new(4, 4);
let src = Bitmap::filled(3, 3, true);
b.paste(&src, -1, 2);
assert_eq!(b.count_black(), 4);
assert!(b.get(0, 2) && b.get(1, 3));
let c = b.crop(-1, 2, 3, 3);
assert_eq!(c.count_black(), 4);
}
#[test]
fn resize_halves_like_pillow() {
// PIL picks the odd source columns when halving (floor(2*i + 1)).
let b = Bitmap::from_fn(4, 1, |x, _| x % 2 == 1);
let r = b.resize_nearest(2, 1);
assert!(r.get(0, 0) && r.get(1, 0));
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Image to raster-instruction conversion (port of `brother_ql.conversion.convert`
//! for black/white images).
use crate::raster::Raster;
use crate::{Bitmap, Error, FormFactor, Label, Model};
/// Rotation applied before rasterising (`rotate=` in brother_ql).
/// Angles are counter-clockwise, as in PIL `Image.rotate`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Rotate {
/// `'auto'`: for die-cut labels, rotate 90 degrees if the image has the
/// printable size transposed. NOTE: for square labels such as 23x23 this is
/// always true, so square images are always rotated 90 degrees CCW (brother_ql
/// behaviour, kept for byte compatibility). Endless labels are not rotated.
#[default]
Auto,
/// No rotation.
R0,
/// 90 degrees counter-clockwise.
R90,
/// 180 degrees.
R180,
/// 270 degrees counter-clockwise (90 clockwise).
R270,
}
/// Conversion options; `Default` matches the Python `convert()` keyword defaults.
#[derive(Debug, Clone, PartialEq)]
pub struct ConvertOptions {
/// Cut after each label (`ESC i M 0x40`, `ESC i A 1`, cut-at-end bit). Default `true`.
pub cut: bool,
/// PackBits-compress raster lines (only on models with compression). Default `false`.
pub compress: bool,
/// Rotation. Default [`Rotate::Auto`].
pub rotate: Rotate,
/// 300x600 dpi mode: input is 600x600 dpi and is halved horizontally. Default `false`.
pub dpi_600: bool,
/// High quality flag (bit 6 of the `ESC i z` valid flags). Default `true`.
pub hq: bool,
/// Threshold in percent (0-100), as in brother_ql. Default `70.0`.
/// For a 1-bit input it only matters at the extremes: a value that maps to
/// 0 (i.e. `>= 100`) turns white pixels black.
pub threshold: f64,
/// Floyd-Steinberg dithering instead of thresholding. Default `false`.
/// Has no effect on 1-bit input (there are no grey levels to diffuse),
/// exactly as in brother_ql; accepted for API parity.
pub dither: bool,
}
impl Default for ConvertOptions {
fn default() -> Self {
Self {
cut: true,
compress: false,
rotate: Rotate::Auto,
dpi_600: false,
hq: true,
threshold: 70.0,
dither: false,
}
}
}
fn rotate(im: &Bitmap, r: Rotate) -> Bitmap {
match r {
Rotate::Auto | Rotate::R0 => im.clone(),
Rotate::R90 => im.rotate90(),
Rotate::R180 => im.rotate180(),
Rotate::R270 => im.rotate270(),
}
}
/// brother_ql: `threshold = min(255, max(0, int((100.0 - threshold) / 100.0 * 255)))`.
fn threshold_value(threshold: f64) -> i64 {
let t = (100.0 - threshold) / 100.0 * 255.0;
// Python int() truncates toward zero; f64 -> i64 `as` does the same (saturating).
(t as i64).clamp(0, 255)
}
/// Converts black/white images into a complete raster instruction stream,
/// byte-for-byte like
/// `brother_ql.conversion.convert(BrotherQLRaster(model), images, label, **opts)`.
///
/// The emitted stream is:
/// `ESC i a 01` (if mode setting), `num_invalidate_bytes` x `00`, `ESC @`,
/// `ESC i a 01` again, then per image: `ESC i S`, `ESC i z` (print info),
/// `ESC i M 40` + `ESC i A 01` (if cut and cutter), `ESC i K` (expanded
/// mode), `ESC i d` (margin), `M 02` (if compress), one `g 00 <n> <row>` per
/// raster line, and `1A`. brother_ql ends *every* page with `1A`, never `0C`.
///
/// Images for die-cut/round labels must be exactly `dots_printable` (or its
/// transpose, or twice that for `dpi_600`); endless images are resized
/// (nearest neighbour, as Pillow does for 1-bit images) to the printable width.
pub fn convert(
model: &Model,
label: &Label,
images: &[Bitmap],
opts: &ConvertOptions,
) -> Result<Vec<u8>, Error> {
let dots_printable = label.dots_printable;
let right_margin_dots = i64::from(label.offset_r) + i64::from(model.additional_offset_r);
let device_pixel_width = model.pixel_width();
let threshold = threshold_value(opts.threshold);
let mut qlr = Raster::new(model);
qlr.add_switch_mode();
qlr.add_invalidate();
qlr.add_initialize();
qlr.add_switch_mode();
for image in images {
let dots_expected = if opts.dpi_600 {
(dots_printable.0 * 2, dots_printable.1 * 2)
} else {
dots_printable
};
let mut im = image.clone();
match label.form_factor {
FormFactor::Endless => {
if !matches!(opts.rotate, Rotate::Auto | Rotate::R0) {
im = rotate(&im, opts.rotate);
}
if opts.dpi_600 {
im = resize(&im, im.width() / 2, im.height())?;
}
if im.width() != dots_printable.0 {
let hsize = (f64::from(dots_printable.0) / f64::from(im.width())
* f64::from(im.height())) as u32;
im = resize(&im, dots_printable.0, hsize)?;
}
if im.width() < device_pixel_width {
let mut new_im = Bitmap::new(device_pixel_width, im.height());
let x =
i64::from(device_pixel_width) - i64::from(im.width()) - right_margin_dots;
new_im.paste(&im, x, 0);
im = new_im;
}
}
FormFactor::DieCut | FormFactor::RoundDieCut => {
if opts.rotate == Rotate::Auto {
if im.width() == dots_expected.1 && im.height() == dots_expected.0 {
im = im.rotate90();
}
} else if opts.rotate != Rotate::R0 {
im = rotate(&im, opts.rotate);
}
if im.size() != dots_expected {
return Err(Error::BadImageDimensions {
size: im.size(),
expected: dots_expected,
});
}
if opts.dpi_600 {
im = resize(&im, im.width() / 2, im.height())?;
}
let mut new_im = Bitmap::new(device_pixel_width, dots_expected.1);
let x = i64::from(device_pixel_width) - i64::from(im.width()) - right_margin_dots;
new_im.paste(&im, x, 0);
im = new_im;
}
}
// convert("L") -> invert -> point(x < threshold ? 0 : 255, "1"):
// black (inverted 255) always prints; white (inverted 0) prints only
// when the threshold is 0. With dither the threshold is not used and
// Floyd-Steinberg on a pure 0/255 image reproduces it unchanged.
if !opts.dither && threshold == 0 {
im = Bitmap::filled(im.width(), im.height(), true);
}
qlr.add_status_information();
let tape_size = label.tape_size;
match label.form_factor {
FormFactor::DieCut | FormFactor::RoundDieCut => {
qlr.mtype = Some(0x0B);
qlr.mwidth = Some(tape_size.0 as u8);
qlr.mlength = Some(tape_size.1 as u8);
}
FormFactor::Endless => {
qlr.mtype = Some(0x0A);
qlr.mwidth = Some(tape_size.0 as u8);
qlr.mlength = Some(0);
}
}
qlr.pquality = opts.hq;
qlr.add_media_and_quality(im.height());
if opts.cut {
qlr.add_autocut(true);
qlr.add_cut_every(1);
}
qlr.dpi_600 = opts.dpi_600;
qlr.cut_at_end = opts.cut;
qlr.two_color_printing = false;
qlr.add_expanded_mode();
qlr.add_margins(label.feed_margin as u16);
if opts.compress {
qlr.add_compression(true);
}
qlr.add_raster_data(&im)?;
qlr.add_print(true);
}
Ok(qlr.data)
}
fn resize(im: &Bitmap, width: u32, height: u32) -> Result<Bitmap, Error> {
if width == 0 || height == 0 {
return Err(Error::BadImageDimensions {
size: (width, height),
expected: (width.max(1), height.max(1)),
});
}
Ok(im.resize_nearest(width, height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{LABEL_23X23, QL_820NWB};
#[test]
fn threshold_mapping() {
assert_eq!(threshold_value(70.0), 76);
assert_eq!(threshold_value(100.0), 0);
assert_eq!(threshold_value(0.0), 255);
assert_eq!(threshold_value(-50.0), 255);
assert_eq!(threshold_value(150.0), 0);
}
#[test]
fn header_and_page_layout_23x23() {
let img = Bitmap::new(202, 202);
let data = convert(&QL_820NWB, &LABEL_23X23, &[img], &ConvertOptions::default()).unwrap();
let mut expected = b"\x1B\x69\x61\x01".to_vec();
expected.extend(std::iter::repeat_n(0u8, 400));
expected.extend_from_slice(b"\x1B\x40\x1B\x69\x61\x01\x1B\x69\x53");
expected.extend_from_slice(b"\x1B\x69\x7A\xCE\x0B\x17\x17\xCA\x00\x00\x00\x00\x00");
expected.extend_from_slice(
b"\x1B\x69\x4D\x40\x1B\x69\x41\x01\x1B\x69\x4B\x08\x1B\x69\x64\x00\x00",
);
assert_eq!(&data[..expected.len()], &expected[..]);
assert_eq!(data.len(), expected.len() + 202 * 93 + 1);
assert_eq!(*data.last().unwrap(), 0x1A);
}
#[test]
fn wrong_size_rejected() {
let img = Bitmap::new(200, 202);
let err =
convert(&QL_820NWB, &LABEL_23X23, &[img], &ConvertOptions::default()).unwrap_err();
assert!(matches!(err, Error::BadImageDimensions { .. }));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Error type.
use std::fmt;
use std::io;
/// Errors from conversion and sending.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
/// No model with this identifier (Python: `BrotherQLUnknownModel`).
UnknownModel(String),
/// No label with this identifier (Python: `KeyError` from `LabelsManager`).
UnknownLabel(String),
/// Die-cut image has the wrong size (Python: `ValueError("Bad image dimensions")`).
BadImageDimensions {
/// Size of the (possibly rotated) image.
size: (u32, u32),
/// Size required by the label.
expected: (u32, u32),
},
/// Raster image width differs from the print head width (Python: `BrotherQLRasterError`).
WrongPixelWidth {
/// Width of the raster image.
width: u32,
/// Head width of the model in dots.
expected: u32,
},
/// Raster height outside the model's `min_max_length_dots` (Python: `BrotherQLRasterError`).
InvalidRasterHeight {
/// Number of raster rows.
height: u32,
/// Minimum allowed rows.
min: u32,
/// Maximum allowed rows.
max: u32,
/// Model identifier.
model: &'static str,
},
/// A (compressed) raster line does not fit the one-byte length field.
RowTooLong(usize),
/// The printer address could not be parsed.
InvalidAddress(String),
/// Name resolution returned no address.
NoAddress(String),
/// Connecting to every resolved address failed (last error is kept).
Connect {
/// The `host:port` that was tried.
addr: String,
/// Error of the last connection attempt.
source: io::Error,
},
/// Sending the instructions failed or timed out.
Write(io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::UnknownModel(m) => write!(f, "unknown printer model {m:?}"),
Error::UnknownLabel(l) => write!(f, "unknown label {l:?}"),
Error::BadImageDimensions { size, expected } => write!(
f,
"bad image dimensions: {}x{}, expecting {}x{}",
size.0, size.1, expected.0, expected.1
),
Error::WrongPixelWidth { width, expected } => {
write!(f, "wrong pixel width: {width}, expected {expected}")
}
Error::InvalidRasterHeight {
height,
min,
max,
model,
} => write!(
f,
"invalid raster height: {height}, expected between {min} and {max} dots for the {model} printer"
),
Error::RowTooLong(n) => {
write!(f, "raster line of {n} bytes does not fit a 1-byte length")
}
Error::InvalidAddress(a) => write!(f, "invalid printer address {a:?}"),
Error::NoAddress(a) => write!(f, "no address found for {a:?}"),
Error::Connect { addr, source } => write!(f, "cannot connect to {addr}: {source}"),
Error::Write(e) => write!(f, "sending to the printer failed: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Connect { source, .. } => Some(source),
Error::Write(e) => Some(e),
_ => None,
}
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Label media (port of `brother_ql/labels.py`).
use crate::Error;
/// Shape of the label media.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FormFactor {
/// Rectangular die-cut labels.
DieCut = 1,
/// Endless (continuous) tape.
Endless = 2,
/// Round die-cut labels.
RoundDieCut = 3,
}
/// Colours the media can carry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Color {
/// Black on white.
BlackWhite = 0,
/// Black and red on white (DK-22251 "62red").
BlackRedWhite = 1,
}
/// A label type, field-for-field like `brother_ql.labels.Label`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Label {
/// Identifier such as `"23x23"`, `"62"` or `"d24"`.
pub identifier: &'static str,
/// (width, length) in mm; length is 0 for endless tape.
pub tape_size: (u32, u32),
/// Die-cut, endless or round.
pub form_factor: FormFactor,
/// Total area (width, length) in dots at 300 dpi.
pub dots_total: (u32, u32),
/// Printable area (width, length) in dots at 300 dpi. Images for die-cut
/// labels must have exactly this size.
pub dots_printable: (u32, u32),
/// Offset from the right side of the print head in dots.
pub offset_r: u32,
/// Feed margin sent with `ESC i d`.
pub feed_margin: u32,
/// Models this label is restricted to (empty = any).
pub restricted_to_models: &'static [&'static str],
/// Black/white or black/red/white.
pub color: Color,
}
const WIDE_102: &[&str] = &["QL-1050", "QL-1060N", "QL-1100", "QL-1100NWB", "QL-1115NWB"];
const WIDE_103: &[&str] = &["QL-1100", "QL-1100NWB"];
const fn label(
identifier: &'static str,
tape_size: (u32, u32),
form_factor: FormFactor,
dots_total: (u32, u32),
dots_printable: (u32, u32),
offset_r: u32,
feed_margin: u32,
) -> Label {
Label {
identifier,
tape_size,
form_factor,
dots_total,
dots_printable,
offset_r,
feed_margin,
restricted_to_models: &[],
color: Color::BlackWhite,
}
}
const fn restricted(l: Label, models: &'static [&'static str]) -> Label {
Label {
restricted_to_models: models,
..l
}
}
use FormFactor::{DieCut, Endless, RoundDieCut};
/// DK-11221, 23 mm x 23 mm die-cut: 202 x 202 printable dots, right offset 42.
pub const LABEL_23X23: Label = label("23x23", (23, 23), DieCut, (272, 272), (202, 202), 42, 0);
/// Every label known to brother_ql 0.12 (`ALL_LABELS`), in the same order.
pub const ALL_LABELS: &[Label] = &[
label("12", (12, 0), Endless, (142, 0), (106, 0), 29, 35),
label("18", (18, 0), Endless, (256, 0), (234, 0), 171, 14),
label("29", (29, 0), Endless, (342, 0), (306, 0), 6, 35),
label("38", (38, 0), Endless, (449, 0), (413, 0), 12, 35),
label("50", (50, 0), Endless, (590, 0), (554, 0), 12, 35),
label("54", (54, 0), Endless, (636, 0), (590, 0), 0, 35),
label("62", (62, 0), Endless, (732, 0), (696, 0), 12, 35),
Label {
color: Color::BlackRedWhite,
..label("62red", (62, 0), Endless, (732, 0), (696, 0), 12, 35)
},
restricted(
label("102", (102, 0), Endless, (1200, 0), (1164, 0), 12, 35),
WIDE_102,
),
restricted(
label("103", (104, 0), Endless, (1224, 0), (1200, 0), 12, 35),
WIDE_103,
),
label("17x54", (17, 54), DieCut, (201, 636), (165, 566), 0, 0),
label("17x87", (17, 87), DieCut, (201, 1026), (165, 956), 0, 0),
LABEL_23X23,
label("29x42", (29, 42), DieCut, (342, 495), (306, 425), 6, 0),
label("29x90", (29, 90), DieCut, (342, 1061), (306, 991), 6, 0),
label("39x90", (38, 90), DieCut, (449, 1061), (413, 991), 12, 0),
label("39x48", (39, 48), DieCut, (461, 565), (425, 495), 6, 0),
label("52x29", (52, 29), DieCut, (614, 341), (578, 271), 0, 0),
label("54x29", (54, 29), DieCut, (630, 341), (598, 271), 60, 0),
label("60x86", (60, 87), DieCut, (708, 1024), (672, 954), 18, 0),
label("62x29", (62, 29), DieCut, (732, 341), (696, 271), 12, 0),
label("62x100", (62, 100), DieCut, (732, 1179), (696, 1109), 12, 0),
restricted(
label("102x51", (102, 51), DieCut, (1200, 596), (1164, 526), 12, 0),
WIDE_102,
),
restricted(
label(
"102x152",
(102, 153),
DieCut,
(1200, 1804),
(1164, 1660),
12,
0,
),
WIDE_102,
),
restricted(
label(
"103x164",
(104, 164),
DieCut,
(1224, 1941),
(1200, 1822),
12,
0,
),
WIDE_103,
),
label("d12", (12, 12), RoundDieCut, (142, 142), (94, 94), 113, 35),
label("d24", (24, 24), RoundDieCut, (284, 284), (236, 236), 42, 0),
label("d58", (58, 58), RoundDieCut, (688, 688), (618, 618), 51, 0),
];
impl Label {
/// Looks a label up by its brother_ql identifier (exact match, e.g. `"23x23"`).
pub fn lookup(identifier: &str) -> Result<&'static Label, Error> {
ALL_LABELS
.iter()
.find(|l| l.identifier == identifier)
.ok_or_else(|| Error::UnknownLabel(identifier.to_string()))
}
/// First label whose `tape_size` matches (like `LabelsManager.find_label_by_size`).
pub fn find_by_size(width_mm: u32, length_mm: u32) -> Option<&'static Label> {
ALL_LABELS
.iter()
.find(|l| l.tape_size == (width_mm, length_mm))
}
/// Human-readable name, as `Label.name` in Python.
pub fn name(&self) -> String {
let mut out = if self.identifier.contains('x') {
format!("{}mm x {}mm die-cut", self.tape_size.0, self.tape_size.1)
} else if self.identifier.starts_with('d') {
format!("{}mm round die-cut", self.tape_size.0)
} else {
format!("{}mm endless", self.tape_size.0)
};
if self.color == Color::BlackRedWhite {
out.push_str(" (black/red/white)");
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_23x23() {
let l = Label::lookup("23x23").unwrap();
assert_eq!(*l, LABEL_23X23);
assert_eq!(l.dots_printable, (202, 202));
assert_eq!(l.offset_r, 42);
assert_eq!(l.form_factor, FormFactor::DieCut);
assert_eq!(l.name(), "23mm x 23mm die-cut");
assert_eq!(ALL_LABELS.len(), 28);
assert_eq!(Label::find_by_size(62, 0).unwrap().identifier, "62");
assert!(Label::lookup("23").is_err());
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Brother QL label printer raster protocol, a byte-identical Rust port of the
//! Python [`brother_ql`](https://pypi.org/project/brother-ql-next/) library
//! (brother_ql_next 0.12) for black/white images.
//!
//! ```no_run
//! use brother_ql::{Bitmap, ConvertOptions, Label, Model, convert, send_network, DEFAULT_TIMEOUT};
//!
//! let model = Model::lookup("QL-820NWB")?;
//! let label = Label::lookup("23x23")?;
//! let mut img = Bitmap::new(202, 202);
//! img.fill_rect(10, 10, 50, 50, true);
//! let data = convert(model, label, &[img], &ConvertOptions::default())?;
//! send_network("tcp://192.168.1.50", &data, true, DEFAULT_TIMEOUT)?;
//! # Ok::<(), brother_ql::Error>(())
//! ```
//!
//! Not ported: red/black two-colour printing (needs colour input), the
//! USB/linux-kernel backends and status parsing (the network backend never
//! reads a status).
mod bitmap;
mod conversion;
mod error;
mod labels;
mod models;
mod network;
pub mod packbits;
pub mod raster;
pub use bitmap::Bitmap;
pub use conversion::{ConvertOptions, Rotate, convert};
pub use error::Error;
pub use labels::{ALL_LABELS, Color, FormFactor, LABEL_23X23, Label};
pub use models::{ALL_MODELS, Model, QL_820NWB};
pub use network::{
DEFAULT_PORT, DEFAULT_TIMEOUT, Outcome, SendStatus, parse_address, send_network,
};
+142
View File
@@ -0,0 +1,142 @@
//! Printer models (port of `brother_ql/models.py`).
use crate::Error;
/// A Brother QL printer model and the protocol features it supports.
///
/// Field names and defaults follow `brother_ql.models.Model`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Model {
/// Identifier such as `"QL-820NWB"`.
pub identifier: &'static str,
/// Minimum and maximum number of raster rows ("dots") per page.
pub min_max_length_dots: (u32, u32),
/// Minimum and maximum feed amount (informational, unused by `convert`).
pub min_max_feed: (u32, u32),
/// Bytes per raster line: 90 for the 720-dot head of the QL-5xx/6xx/7xx/8xx,
/// 162 (1296 dots) for the wide QL-10xx/11xx.
pub number_bytes_per_row: u32,
/// Extra right offset added to the label's `offset_r`.
pub additional_offset_r: u32,
/// Supports `ESC i a` (switch to raster mode).
pub mode_setting: bool,
/// Has an automatic cutter (`ESC i M`, `ESC i A`).
pub cutting: bool,
/// Supports `ESC i K` (expanded mode: cut at end, 600 dpi, two-colour).
pub expanded_mode: bool,
/// Supports TIFF/PackBits compressed raster lines (`M 0x02`).
pub compression: bool,
/// Supports black/red two-colour printing.
pub two_color: bool,
/// Number of NUL bytes sent as the "invalidate" preamble.
pub num_invalidate_bytes: u32,
}
const fn model(identifier: &'static str, min_max_length_dots: (u32, u32)) -> Model {
Model {
identifier,
min_max_length_dots,
min_max_feed: (35, 1500),
number_bytes_per_row: 90,
additional_offset_r: 0,
mode_setting: true,
cutting: true,
expanded_mode: true,
compression: true,
two_color: false,
num_invalidate_bytes: 200,
}
}
const fn no_compression_no_mode(m: Model) -> Model {
Model {
compression: false,
mode_setting: false,
..m
}
}
const fn wide(m: Model) -> Model {
Model {
number_bytes_per_row: 162,
additional_offset_r: 44,
..m
}
}
/// QL-820NWB (the lamp label printer): 720-dot head, 90 bytes per line,
/// cutter, compression, mode switching, two-colour capable, 400 invalidate bytes.
pub const QL_820NWB: Model = Model {
two_color: true,
num_invalidate_bytes: 400,
..model("QL-820NWB", (150, 11811))
};
/// Every model known to brother_ql 0.12 (`ALL_MODELS`), in the same order.
pub const ALL_MODELS: &[Model] = &[
Model {
compression: false,
mode_setting: false,
expanded_mode: false,
cutting: false,
..model("QL-500", (295, 11811))
},
no_compression_no_mode(model("QL-550", (295, 11811))),
no_compression_no_mode(model("QL-560", (295, 11811))),
no_compression_no_mode(model("QL-570", (150, 11811))),
model("QL-580N", (150, 11811)),
model("QL-600", (150, 11811)),
model("QL-650TD", (295, 11811)),
no_compression_no_mode(model("QL-700", (150, 11811))),
model("QL-710W", (150, 11811)),
model("QL-720NW", (150, 11811)),
Model {
two_color: true,
compression: false,
num_invalidate_bytes: 400,
..model("QL-800", (150, 11811))
},
Model {
two_color: true,
num_invalidate_bytes: 400,
..model("QL-810W", (150, 11811))
},
QL_820NWB,
wide(model("QL-1050", (295, 35433))),
wide(model("QL-1060N", (295, 35433))),
wide(model("QL-1100", (301, 35434))),
wide(model("QL-1100NWB", (301, 35434))),
wide(model("QL-1110NWB", (301, 35434))),
wide(model("QL-1115NWB", (301, 35434))),
];
impl Model {
/// Looks a model up by its brother_ql identifier (exact match, e.g. `"QL-820NWB"`).
pub fn lookup(identifier: &str) -> Result<&'static Model, Error> {
ALL_MODELS
.iter()
.find(|m| m.identifier == identifier)
.ok_or_else(|| Error::UnknownModel(identifier.to_string()))
}
/// Width of a raster line in dots (`number_bytes_per_row * 8`).
pub fn pixel_width(&self) -> u32 {
self.number_bytes_per_row * 8
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ql820nwb() {
let m = Model::lookup("QL-820NWB").unwrap();
assert_eq!(*m, QL_820NWB);
assert_eq!(m.pixel_width(), 720);
assert_eq!(m.num_invalidate_bytes, 400);
assert!(m.two_color && m.compression && m.mode_setting && m.cutting && m.expanded_mode);
assert_eq!(ALL_MODELS.len(), 19);
assert!(Model::lookup("QL-9999").is_err());
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Network sending (port of `brother_ql.backends.network` +
//! `brother_ql.backends.helpers.send(..., backend_identifier="network")`).
//!
//! What happens on the wire, step by step (identical to the Python code):
//!
//! 1. The address is parsed like `urllib.parse.urlparse`: `tcp://` is
//! prepended if missing, the host is lower-cased (IPv6 in `[...]`), any
//! path/user-info is ignored, and a missing or `0` port becomes **9100**
//! (raw "JetDirect" printing port of the QL-820NWB).
//! 2. The host is resolved (getaddrinfo, any family, TCP) and each address is
//! tried in order until one connects. `TCP_NODELAY` is enabled.
//! 3. All instruction bytes are written with `sendall` semantics.
//! 4. Nothing is ever read back. `send()` returns right after the write,
//! even with `blocking=True`: the Python helper explicitly skips the
//! status loop for the network backend ("doesn't support readback"). The
//! `ESC i S` status requests inside the job are therefore never answered
//! from the host's point of view; completion is *not* detected.
//! 5. The socket is shut down in both directions and closed (Python does this
//! in `__del__` when `send()` returns); shutdown errors are ignored.
//!
//! Differences to Python, all deliberate: the `timeout` argument also bounds
//! each connection attempt (Python uses the OS connect timeout); Python's
//! write timeout is a fixed 10 s for the whole `sendall`, here it is
//! `timeout` (pass [`DEFAULT_TIMEOUT`] for 10 s), also for the whole write,
//! which is issued in 64 KiB `write()` calls so the deadline also works on
//! Windows (the TCP byte stream is the same). The `tcp://` prefix is matched
//! case-insensitively and an empty host is an error (Python would fall back
//! to localhost).
//!
//! Caveat inherited from Python: the job contains one `ESC i S` status
//! request per label, a QL printer answers them on the same socket, and the
//! answers are never read before the socket is closed.
use std::io::{self, Write};
use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use crate::Error;
/// Default TCP port of the printer (raw printing, 9100).
pub const DEFAULT_PORT: u16 = 9100;
/// Write timeout used by the Python backend (`settimeout(10)` around `sendall`).
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
/// Outcome of a send, as the Python `status['outcome']` string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// `'unknown'`
Unknown,
/// `'sent'`: the bytes were written (always the result for the network backend).
Sent,
/// `'printed'`: the printer reported "Printing completed" (never for network).
Printed,
/// `'error'`: the printer reported an error (never for network).
Error,
}
/// The status dictionary returned by `brother_ql.backends.helpers.send`.
/// `printer_state` is omitted because the network backend never reads one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SendStatus {
/// The instructions were written to the socket.
pub instructions_sent: bool,
/// Always [`Outcome::Sent`] for the network backend.
pub outcome: Outcome,
/// Always `false`: no read-back over the network.
pub did_print: bool,
/// Always `false`: no read-back over the network.
pub ready_for_next_job: bool,
}
/// Parses `tcp://host[:port]` or `host[:port]` into `(host, port)` like the
/// Python network backend (urlparse; port defaults to 9100).
pub fn parse_address(addr: &str) -> Result<(String, u16), Error> {
let invalid = || Error::InvalidAddress(addr.to_string());
let rest = match addr.get(..6) {
Some(p) if p.eq_ignore_ascii_case("tcp://") => &addr[6..],
_ => addr,
};
// netloc ends at the first '/', '?' or '#'
let netloc = rest.split(['/', '?', '#']).next().unwrap_or("");
// strip user-info
let hostinfo = netloc.rsplit_once('@').map_or(netloc, |(_, h)| h);
let (host, port) = if let Some(after) = hostinfo.strip_prefix('[') {
let (h, tail) = after.split_once(']').ok_or_else(invalid)?;
let port = match tail.strip_prefix(':') {
Some(p) => p,
None if tail.is_empty() => "",
None => return Err(invalid()),
};
(h, port)
} else {
match hostinfo.split_once(':') {
Some((h, p)) => (h, p),
None => (hostinfo, ""),
}
};
if host.is_empty() {
return Err(invalid());
}
let port = if port.is_empty() {
0
} else {
if !port.bytes().all(|b| b.is_ascii_digit()) {
return Err(invalid());
}
port.parse::<u16>().map_err(|_| invalid())?
};
let port = if port == 0 { DEFAULT_PORT } else { port };
Ok((host.to_ascii_lowercase(), port))
}
/// Sends raster instructions to a networked Brother QL printer, mirroring
/// `brother_ql.backends.helpers.send(instructions, addr, "network", blocking)`.
///
/// `addr` is `tcp://host[:port]` or `host[:port]` (port defaults to 9100).
/// `blocking` is accepted for parity but, exactly like Python, has no effect
/// for the network backend: the function returns as soon as all bytes are
/// written and the socket is closed; it never waits for or reads a printer
/// status. `timeout` bounds each connection attempt and the complete write
/// ([`Duration::ZERO`] = no timeout).
///
/// Returns `SendStatus { instructions_sent: true, outcome: Sent, did_print:
/// false, ready_for_next_job: false }` on success.
pub fn send_network(
addr: &str,
data: &[u8],
blocking: bool,
timeout: Duration,
) -> Result<SendStatus, Error> {
let _ = blocking; // the network backend has no read-back, see module docs
let (host, port) = parse_address(addr)?;
let addrs: Vec<SocketAddr> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|e| Error::Connect {
addr: format!("{host}:{port}"),
source: e,
})?
.collect();
if addrs.is_empty() {
return Err(Error::NoAddress(format!("{host}:{port}")));
}
let mut stream = None;
let mut last_err = None;
for sa in &addrs {
let res = if timeout.is_zero() {
TcpStream::connect(sa)
} else {
TcpStream::connect_timeout(sa, timeout)
};
match res {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last_err = Some(e),
}
}
let mut stream = match stream {
Some(s) => s,
None => {
return Err(Error::Connect {
addr: format!("{host}:{port}"),
source: last_err.unwrap_or_else(|| io::Error::other("no address")),
});
}
};
// Python sets TCP_NODELAY before connect(); before the first write is equivalent.
let _ = stream.set_nodelay(true);
let result = write_all_deadline(&mut stream, data, timeout);
// dispose(): shutdown(SHUT_RDWR) + close(), errors ignored
let _ = stream.shutdown(Shutdown::Both);
drop(stream);
result.map_err(Error::Write)?;
Ok(SendStatus {
instructions_sent: true,
outcome: Outcome::Sent,
did_print: false,
ready_for_next_job: false,
})
}
/// Largest single `write()`; keeps the deadline effective on Windows.
const WRITE_CHUNK: usize = 64 * 1024;
/// `socket.sendall` with a total deadline (CPython >= 3.5 semantics).
fn write_all_deadline(stream: &mut TcpStream, data: &[u8], timeout: Duration) -> io::Result<()> {
let deadline = (!timeout.is_zero()).then(|| Instant::now() + timeout);
let mut off = 0;
while off < data.len() {
if let Some(deadline) = deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(io::ErrorKind::TimedOut, "timed out"));
}
stream.set_write_timeout(Some(remaining))?;
}
// Bounded chunks so a stalled printer is noticed per chunk (Windows
// accepts arbitrarily large single sends into its loopback buffers).
let end = data.len().min(off + WRITE_CHUNK);
match stream.write(&data[off..end]) {
Ok(0) => return Err(io::ErrorKind::WriteZero.into()),
Ok(n) => off += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
return Err(io::Error::new(io::ErrorKind::TimedOut, "timed out"));
}
Err(e) => return Err(e),
}
}
stream.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn address_parsing() {
assert_eq!(
parse_address("tcp://192.168.1.50").unwrap(),
("192.168.1.50".into(), 9100)
);
assert_eq!(
parse_address("192.168.1.50").unwrap(),
("192.168.1.50".into(), 9100)
);
assert_eq!(
parse_address("tcp://Printer.local:9101/x").unwrap(),
("printer.local".into(), 9101)
);
assert_eq!(
parse_address("tcp://[fe80::1]:9100").unwrap(),
("fe80::1".into(), 9100)
);
assert_eq!(parse_address("tcp://[::1]").unwrap(), ("::1".into(), 9100));
assert_eq!(parse_address("host:0").unwrap(), ("host".into(), 9100));
assert_eq!(parse_address("host:").unwrap(), ("host".into(), 9100));
assert!(parse_address("tcp://").is_err());
assert!(parse_address("host:99999").is_err());
assert!(parse_address("host:abc").is_err());
}
}
+142
View File
@@ -0,0 +1,142 @@
//! PackBits run-length encoding, a line-for-line port of the `packbits` 0.6
//! Python package used by brother_ql (its exact run splitting matters for
//! byte-identical output).
/// Encodes `data` with PackBits exactly like `packbits.encode`.
pub fn encode(data: &[u8]) -> Vec<u8> {
if data.is_empty() {
return Vec::new();
}
if data.len() == 1 {
return vec![0, data[0]];
}
const MAX_LENGTH: usize = 127;
#[derive(PartialEq)]
enum State {
Raw,
Rle,
}
let mut result = Vec::with_capacity(data.len() + data.len() / 128 + 2);
let mut buf: Vec<u8> = Vec::with_capacity(MAX_LENGTH);
let mut pos = 0usize;
let mut repeat_count = 0usize;
let mut state = State::Raw;
fn finish_raw(result: &mut Vec<u8>, buf: &mut Vec<u8>) {
if buf.is_empty() {
return;
}
result.push((buf.len() - 1) as u8);
result.extend_from_slice(buf);
buf.clear();
}
fn finish_rle(result: &mut Vec<u8>, repeat_count: usize, byte: u8) {
result.push((256 - (repeat_count - 1)) as u8);
result.push(byte);
}
while pos < data.len() - 1 {
let current_byte = data[pos];
if data[pos] == data[pos + 1] {
match state {
State::Raw => {
finish_raw(&mut result, &mut buf);
state = State::Rle;
repeat_count = 1;
}
State::Rle => {
if repeat_count == MAX_LENGTH {
finish_rle(&mut result, repeat_count, data[pos]);
repeat_count = 0;
}
repeat_count += 1;
}
}
} else {
match state {
State::Rle => {
repeat_count += 1;
finish_rle(&mut result, repeat_count, data[pos]);
state = State::Raw;
repeat_count = 0;
}
State::Raw => {
if buf.len() == MAX_LENGTH {
finish_raw(&mut result, &mut buf);
}
buf.push(current_byte);
}
}
}
pos += 1;
}
if state == State::Raw {
buf.push(data[pos]);
finish_raw(&mut result, &mut buf);
} else {
repeat_count += 1;
finish_rle(&mut result, repeat_count, data[pos]);
}
result
}
/// Decodes PackBits data (like `packbits.decode`).
pub fn decode(data: &[u8]) -> Vec<u8> {
let mut result = Vec::new();
let mut pos = 0usize;
while pos < data.len() {
let header = data[pos] as i8;
pos += 1;
if header >= 0 {
let n = header as usize + 1;
let end = (pos + n).min(data.len());
result.extend_from_slice(&data[pos..end]);
pos += n;
} else if header == -128 {
// no-op
} else {
if pos < data.len() {
let n = (1 - i32::from(header)) as usize;
result.extend(std::iter::repeat_n(data[pos], n));
}
pos += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_vectors() {
// Apple's PackBits example (as produced by the Python implementation)
let raw = [
0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A, 0x22,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
];
let enc = encode(&raw);
assert_eq!(
enc,
[
0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A, 0xFD, 0xAA, 0x03, 0x80, 0x00, 0x2A, 0x22, 0xF7,
0xAA
]
);
assert_eq!(decode(&enc), raw);
assert_eq!(encode(&[]), Vec::<u8>::new());
assert_eq!(encode(&[7]), vec![0, 7]);
assert_eq!(encode(&[0u8; 90]), vec![(256 - 89) as u8, 0]);
}
#[test]
fn roundtrip_long_runs() {
let mut data = vec![0u8; 300];
data.extend((0..=255u8).cycle().take(400));
assert_eq!(decode(&encode(&data)), data);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Low-level raster command builder (port of `brother_ql.raster.BrotherQLRaster`).
//!
//! Each `add_*` method appends one command to [`Raster::data`]. Commands the
//! model does not support are skipped silently, exactly like the Python class
//! with its default `exception_on_warning = False` (Python logs a warning).
use crate::{Bitmap, Error, Model, packbits};
/// Instruction buffer for one print job.
#[derive(Debug, Clone)]
pub struct Raster {
/// The model the instructions are generated for.
pub model: Model,
/// The instruction bytes generated so far.
pub data: Vec<u8>,
/// Page counter. Only reset by `add_initialize`; brother_ql never
/// increments it, so the "starting page" byte of `ESC i z` is always 0.
pub page_number: u32,
/// Bit 3 of `ESC i K`.
pub cut_at_end: bool,
/// Bit 6 of `ESC i K`.
pub dpi_600: bool,
/// Bit 0 of `ESC i K`.
pub two_color_printing: bool,
/// Media type for `ESC i z` (0x0A endless, 0x0B die-cut).
pub mtype: Option<u8>,
/// Media width in mm for `ESC i z`.
pub mwidth: Option<u8>,
/// Media length in mm for `ESC i z` (0 for endless).
pub mlength: Option<u8>,
/// Print quality flag (bit 6 of the `ESC i z` valid flags).
pub pquality: bool,
compression: bool,
}
impl Raster {
/// A new empty instruction buffer for `model`.
pub fn new(model: &Model) -> Self {
Self {
model: *model,
data: Vec::new(),
page_number: 0,
cut_at_end: true,
dpi_600: false,
two_color_printing: false,
mtype: None,
mwidth: None,
mlength: None,
pquality: true,
compression: false,
}
}
/// `ESC @` (initialize); resets the page counter.
pub fn add_initialize(&mut self) {
self.page_number = 0;
self.data.extend_from_slice(b"\x1B\x40");
}
/// `ESC i S` (status information request).
pub fn add_status_information(&mut self) {
self.data.extend_from_slice(b"\x1B\x69\x53");
}
/// `ESC i a 0x01` (switch to raster mode) if the model supports mode setting.
pub fn add_switch_mode(&mut self) {
if self.model.mode_setting {
self.data.extend_from_slice(b"\x1B\x69\x61\x01");
}
}
/// `num_invalidate_bytes` NUL bytes (clears the printer's command buffer).
pub fn add_invalidate(&mut self) {
self.data.resize(
self.data.len() + self.model.num_invalidate_bytes as usize,
0,
);
}
/// `ESC i z` print information: valid flags, media type/width/length,
/// raster line count (u32 LE), starting-page flag, 0x00.
pub fn add_media_and_quality(&mut self, rnumber: u32) {
self.data.extend_from_slice(b"\x1B\x69\x7A");
let mut valid_flags: u8 = 0x80;
valid_flags |= u8::from(self.mtype.is_some()) << 1;
valid_flags |= u8::from(self.mwidth.is_some()) << 2;
valid_flags |= u8::from(self.mlength.is_some()) << 3;
valid_flags |= u8::from(self.pquality) << 6;
self.data.push(valid_flags);
for v in [self.mtype, self.mwidth, self.mlength] {
self.data.push(v.unwrap_or(0));
}
self.data.extend_from_slice(&rnumber.to_le_bytes());
self.data.push(if self.page_number == 0 { 0 } else { 1 });
self.data.push(0);
}
/// `ESC i M` various mode: auto cut flag in bit 6 (cutter models only).
pub fn add_autocut(&mut self, autocut: bool) {
if self.model.cutting {
self.data.extend_from_slice(b"\x1B\x69\x4D");
self.data.push(u8::from(autocut) << 6);
}
}
/// `ESC i A n`: cut every `n` labels (cutter models only).
pub fn add_cut_every(&mut self, n: u8) {
if self.model.cutting {
self.data.extend_from_slice(b"\x1B\x69\x41");
self.data.push(n);
}
}
/// `ESC i K` expanded mode: bit 0 two-colour, bit 3 cut at end, bit 6 600 dpi.
pub fn add_expanded_mode(&mut self) {
if !self.model.expanded_mode {
return;
}
if self.two_color_printing && !self.model.two_color {
return;
}
self.data.extend_from_slice(b"\x1B\x69\x4B");
let mut flags = 0u8;
flags |= u8::from(self.cut_at_end) << 3;
flags |= u8::from(self.dpi_600) << 6;
flags |= u8::from(self.two_color_printing);
self.data.push(flags);
}
/// `ESC i d` margin (feed) amount in dots, u16 LE.
pub fn add_margins(&mut self, dots: u16) {
self.data.extend_from_slice(b"\x1B\x69\x64");
self.data.extend_from_slice(&dots.to_le_bytes());
}
/// `M 0x02` / `M 0x00` (PackBits compression on/off), compression models only.
pub fn add_compression(&mut self, compression: bool) {
if self.model.compression {
self.compression = compression;
self.data.push(0x4D);
self.data.push(u8::from(compression) << 1);
}
}
/// Raster line width in dots.
pub fn pixel_width(&self) -> u32 {
self.model.pixel_width()
}
/// Appends the raster lines of a black/white image that is already
/// `pixel_width()` dots wide (`true` = print a dot). Each line is mirrored
/// left-right, packed MSB-first and sent as `g 0x00 <len> <bytes>`
/// (PackBits-compressed if compression was enabled).
pub fn add_raster_data(&mut self, image: &Bitmap) -> Result<(), Error> {
let expected = self.pixel_width();
if image.width() != expected {
return Err(Error::WrongPixelWidth {
width: image.width(),
expected,
});
}
let (min, max) = self.model.min_max_length_dots;
if !(min..=max).contains(&image.height()) {
return Err(Error::InvalidRasterHeight {
height: image.height(),
min,
max,
model: self.model.identifier,
});
}
let row_len = (expected / 8) as usize;
let mut row = vec![0u8; row_len];
for y in 0..image.height() {
row.fill(0);
for (x, &black) in image.row(y).iter().enumerate() {
if black {
// FLIP_LEFT_RIGHT, then pack MSB first
let fx = expected as usize - 1 - x;
row[fx / 8] |= 0x80 >> (fx % 8);
}
}
self.data.extend_from_slice(b"\x67\x00");
let payload = if self.compression {
packbits::encode(&row)
} else {
row.clone()
};
let len = u8::try_from(payload.len()).map_err(|_| Error::RowTooLong(payload.len()))?;
self.data.push(len);
self.data.extend_from_slice(&payload);
}
Ok(())
}
/// Print command: `0x1A` (print + feed, last page) or `0x0C` (form feed,
/// more pages follow). Note `convert` always uses `last_page = true`, as
/// brother_ql does.
pub fn add_print(&mut self, last_page: bool) {
self.data.push(if last_page { 0x1A } else { 0x0C });
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Byte-for-byte comparison against Python brother_ql output.
//!
//! The golden data is produced by `tools/golden.py` (brother_ql_next 0.12 +
//! Pillow): each manifest line names the input PNG(s), model, label and
//! `convert()` keyword arguments; `<name>.bin` is what Python produced.
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use brother_ql::{Bitmap, ConvertOptions, Label, Model, Rotate, convert};
fn golden_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_png(path: &Path) -> Bitmap {
let mut decoder = png::Decoder::new(File::open(path).unwrap());
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().unwrap();
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).unwrap();
let channels = match info.color_type {
png::ColorType::Grayscale => 1,
png::ColorType::GrayscaleAlpha => 2,
png::ColorType::Rgb => 3,
png::ColorType::Rgba => 4,
png::ColorType::Indexed => panic!("palette not expanded"),
};
let (w, h) = (info.width, info.height);
Bitmap::from_fn(w, h, |x, y| {
let i = y as usize * info.line_size + x as usize * channels;
buf[i] < 128
})
}
fn parse_bool(v: &str) -> bool {
match v {
"True" | "true" | "1" => true,
"False" | "false" | "0" => false,
_ => panic!("bad bool {v}"),
}
}
fn parse_options(tokens: &[&str]) -> ConvertOptions {
let mut o = ConvertOptions::default();
for t in tokens {
let (k, v) = t.split_once('=').unwrap();
match k {
"cut" => o.cut = parse_bool(v),
"compress" => o.compress = parse_bool(v),
"dpi_600" => o.dpi_600 = parse_bool(v),
"hq" => o.hq = parse_bool(v),
"dither" => o.dither = parse_bool(v),
"threshold" => o.threshold = v.parse().unwrap(),
"rotate" => {
o.rotate = match v {
"auto" => Rotate::Auto,
"0" => Rotate::R0,
"90" => Rotate::R90,
"180" => Rotate::R180,
"270" => Rotate::R270,
_ => panic!("bad rotate {v}"),
}
}
_ => panic!("unknown option {k}"),
}
}
o
}
fn first_difference(a: &[u8], b: &[u8]) -> Option<usize> {
a.iter()
.zip(b)
.position(|(x, y)| x != y)
.or_else(|| (a.len() != b.len()).then_some(a.len().min(b.len())))
}
#[test]
fn convert_matches_python_byte_for_byte() {
let dir = golden_dir();
let manifest = std::fs::read_to_string(dir.join("manifest.txt"))
.expect("golden data missing: run tools/golden.py");
let mut images: HashMap<String, Bitmap> = HashMap::new();
let mut failures = Vec::new();
let mut passed = 0;
let mut label_cases = 0;
for line in manifest.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
let (name, model, label, pngs) = (tokens[0], tokens[1], tokens[2], tokens[3]);
let opts = parse_options(&tokens[4..]);
let bitmaps: Vec<Bitmap> = pngs
.split(',')
.map(|p| {
images
.entry(p.to_string())
.or_insert_with(|| load_png(&dir.join(p)))
.clone()
})
.collect();
let model = Model::lookup(model).unwrap();
let label = Label::lookup(label).unwrap();
let expected = std::fs::read(dir.join(format!("{name}.bin"))).unwrap();
let actual = convert(model, label, &bitmaps, &opts)
.unwrap_or_else(|e| panic!("{name}: convert failed: {e}"));
match first_difference(&actual, &expected) {
None => {
passed += 1;
if name.starts_with("label_L") {
label_cases += 1;
}
}
Some(at) => failures.push(format!(
"{name}: differs at byte {at} (rust {} bytes, python {} bytes): rust {:02X?} python {:02X?}",
actual.len(),
expected.len(),
&actual[at..(at + 8).min(actual.len())],
&expected[at..(at + 8).min(expected.len())],
)),
}
}
println!("{passed} golden cases byte-identical ({label_cases} lamp-label cases)");
assert!(
failures.is_empty(),
"{} mismatches:\n{}",
failures.len(),
failures.join("\n")
);
assert!(passed >= 50, "too few golden cases ({passed})");
}
#[test]
fn convert_rejects_like_python() {
let model = Model::lookup("QL-820NWB").unwrap();
// d12 has 94 printable rows, below the QL-820NWB minimum of 150
let d12 = Label::lookup("d12").unwrap();
let err = convert(
model,
d12,
&[Bitmap::new(94, 94)],
&ConvertOptions::default(),
)
.unwrap_err();
assert!(
matches!(
err,
brother_ql::Error::InvalidRasterHeight { height: 94, .. }
),
"{err}"
);
let l = Label::lookup("23x23").unwrap();
let err = convert(
model,
l,
&[Bitmap::new(202, 201)],
&ConvertOptions::default(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"bad image dimensions: 202x201, expecting 202x202"
);
assert!(Model::lookup("QL-9").is_err());
assert!(Label::lookup("23").is_err());
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
# name model label images options... (generated by tools/golden.py)
label_L0001_x1 QL-820NWB 23x23 label_L0001.png cut=True
label_L0001_x2 QL-820NWB 23x23 label_L0001.png,label_L0001.png cut=True
label_L0006_x1 QL-820NWB 23x23 label_L0006.png cut=True
label_L0006_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png cut=True
label_L0123_x1 QL-820NWB 23x23 label_L0123.png cut=True
label_L0123_x2 QL-820NWB 23x23 label_L0123.png,label_L0123.png cut=True
label_L12345_x1 QL-820NWB 23x23 label_L12345.png cut=True
label_L12345_x2 QL-820NWB 23x23 label_L12345.png,label_L12345.png cut=True
label_L9_x1 QL-820NWB 23x23 label_L9.png cut=True
label_L9_x2 QL-820NWB 23x23 label_L9.png,label_L9.png cut=True
white_202_x1 QL-820NWB 23x23 white_202.png cut=True
white_202_x2 QL-820NWB 23x23 white_202.png,white_202.png cut=True
black_202_x1 QL-820NWB 23x23 black_202.png cut=True
black_202_x2 QL-820NWB 23x23 black_202.png,black_202.png cut=True
checker_202_x1 QL-820NWB 23x23 checker_202.png cut=True
checker_202_x2 QL-820NWB 23x23 checker_202.png,checker_202.png cut=True
corner_tl_x1 QL-820NWB 23x23 corner_tl.png cut=True
corner_tl_x2 QL-820NWB 23x23 corner_tl.png,corner_tl.png cut=True
corner_tr_x1 QL-820NWB 23x23 corner_tr.png cut=True
corner_tr_x2 QL-820NWB 23x23 corner_tr.png,corner_tr.png cut=True
corner_bl_x1 QL-820NWB 23x23 corner_bl.png cut=True
corner_bl_x2 QL-820NWB 23x23 corner_bl.png,corner_bl.png cut=True
corner_br_x1 QL-820NWB 23x23 corner_br.png cut=True
corner_br_x2 QL-820NWB 23x23 corner_br.png,corner_br.png cut=True
random_202_x1 QL-820NWB 23x23 random_202.png cut=True
random_202_x2 QL-820NWB 23x23 random_202.png,random_202.png cut=True
label_L0006_nocut QL-820NWB 23x23 label_L0006.png cut=False
label_L0006_compress_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png compress=True
random_202_compress QL-820NWB 23x23 random_202.png compress=True
checker_202_compress QL-820NWB 23x23 checker_202.png compress=True
black_202_compress QL-820NWB 23x23 black_202.png compress=True
white_202_compress QL-820NWB 23x23 white_202.png compress=True
corner_tl_rot0 QL-820NWB 23x23 corner_tl.png rotate=0
corner_tl_rot90 QL-820NWB 23x23 corner_tl.png rotate=90
corner_tl_rot180 QL-820NWB 23x23 corner_tl.png rotate=180
corner_tl_rot270 QL-820NWB 23x23 corner_tl.png rotate=270
corner_br_rot0 QL-820NWB 23x23 corner_br.png rotate=0
random_202_thr100 QL-820NWB 23x23 random_202.png threshold=100
random_202_thr0 QL-820NWB 23x23 random_202.png threshold=0
random_202_thr50 QL-820NWB 23x23 random_202.png threshold=50
random_202_lq QL-820NWB 23x23 random_202.png hq=False
random_202_dither QL-820NWB 23x23 random_202.png dither=True
random_202_dither_thr100 QL-820NWB 23x23 random_202.png dither=True threshold=100
multi_labels QL-820NWB 23x23 label_L0001.png,label_L0006.png,label_L12345.png cut=True
random_404_dpi600 QL-820NWB 23x23 random_404.png dpi_600=True
random_404_dpi600_compress QL-820NWB 23x23 random_404.png dpi_600=True compress=True
endless62_696 QL-820NWB 62 random_696x300.png
endless62_resize500 QL-820NWB 62 random_500x200.png
endless62_rot90 QL-820NWB 62 random_300x696.png rotate=90
endless62_dpi600 QL-820NWB 62 random_1392x150.png dpi_600=True
endless29_306 QL-820NWB 29 random_306x300.png compress=True
diecut62x29_auto_rotate QL-820NWB 62x29 random_271x696.png
round_d24 QL-820NWB d24 random_236.png
ql1100_102 QL-1100 102 sparse_1164x301.png compress=True
ql1100_62 QL-1100 62 sparse_696x301.png compress=True
ql700_62_compress_ignored QL-700 62 random_696x300.png compress=True
ql500_29 QL-500 29 random_306x300.png
ql570_62x29 QL-570 62x29 random_696x271.png cut=False
ql800_23x23_x2 QL-800 23x23 label_L0006.png,label_L0006.png compress=True
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
//! `send_network` against a fake printer on 127.0.0.1.
//!
//! The Python network backend never reads from the socket (helpers.send
//! returns right after the write, even with `blocking=True`), so the fake
//! printers here check: the exact bytes arrive, the client closes the
//! connection on its own (EOF) right after writing, and it does not wait for
//! the status replies a real QL printer sends.
use std::io::{Read, Write};
use std::net::{Shutdown, TcpListener};
use std::path::Path;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use brother_ql::{Error, Outcome, SendStatus, send_network};
fn job() -> Vec<u8> {
// a real two-label job for L0006 as produced by Python brother_ql
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/label_L0006_x2.bin");
std::fs::read(p).expect("golden data")
}
const SENT: SendStatus = SendStatus {
instructions_sent: true,
outcome: Outcome::Sent,
did_print: false,
ready_for_next_job: false,
};
/// 32-byte QL status reply (80 20 42 ...): model QL-820NWB (0x41), 23 mm
/// die-cut media, given status/phase type.
fn status_reply(status_type: u8, phase_type: u8) -> [u8; 32] {
let mut r = [0u8; 32];
r[0] = 0x80;
r[1] = 0x20;
r[2] = 0x42;
r[3] = 0x34;
r[4] = 0x41;
r[5] = 0x30;
r[10] = 23; // media width
r[11] = 0x0B; // die-cut
r[17] = 23; // media length
r[18] = status_type;
r[19] = phase_type;
r
}
#[test]
fn sends_exact_bytes_and_closes() {
let data = job();
for prefix in ["tcp://", ""] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).unwrap(); // returns only once the client closed
got
});
let status = send_network(
&format!("{prefix}127.0.0.1:{port}"),
&data,
true,
Duration::from_secs(5),
)
.unwrap();
assert_eq!(status, SENT);
let got = server.join().unwrap();
assert_eq!(got.len(), data.len());
assert!(got == data, "received bytes differ");
}
}
#[test]
fn blocking_does_not_wait_for_status_replies() {
let data = job();
let n = data.len();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = vec![0u8; n];
s.read_exact(&mut got).unwrap();
// A real printer answers ESC i S and later reports "printing
// completed" (0x01) and "phase change: waiting to receive" (0x06/0x00).
// Hold them back for a while: the client must not wait for them.
let eof = {
let mut buf = [0u8; 1];
s.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
matches!(s.read(&mut buf), Ok(0))
};
tx.send(()).unwrap();
let _ = s.write_all(&status_reply(0x01, 0x00));
let _ = s.write_all(&status_reply(0x06, 0x00));
let _ = s.shutdown(Shutdown::Both);
(got, eof)
});
let t0 = Instant::now();
let status = send_network(
&format!("tcp://127.0.0.1:{port}"),
&data,
true,
Duration::from_secs(5),
)
.unwrap();
let elapsed = t0.elapsed();
assert_eq!(status, SENT, "network backend never reports printed/ready");
let (got, eof) = server.join().unwrap();
rx.recv().unwrap();
assert!(got == data);
assert!(eof, "client did not close the connection after writing");
assert!(
elapsed < Duration::from_secs(2),
"send_network waited {elapsed:?}"
);
}
#[test]
fn non_blocking_is_identical() {
let data = b"\x1B\x40hello".to_vec();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).unwrap();
got
});
let status = send_network(
&format!("127.0.0.1:{port}"),
&data,
false,
Duration::from_secs(5),
)
.unwrap();
assert_eq!(status, SENT);
assert_eq!(server.join().unwrap(), data);
}
#[test]
fn connection_refused_is_an_error() {
let port = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let err = send_network(
&format!("tcp://127.0.0.1:{port}"),
b"x",
true,
Duration::from_secs(2),
)
.unwrap_err();
assert!(matches!(err, Error::Connect { .. }), "{err}");
let err = send_network("tcp://", b"x", true, Duration::from_secs(2)).unwrap_err();
assert!(matches!(err, Error::InvalidAddress(_)), "{err}");
}
#[test]
fn write_times_out_when_printer_stalls() {
// The fake printer accepts but never reads, so the socket buffers fill up.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done_tx, done_rx) = mpsc::channel::<()>();
let server = thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let _ = done_rx.recv_timeout(Duration::from_secs(30));
drop(s);
});
let data = vec![0u8; 64 << 20];
let t0 = Instant::now();
let res = send_network(
&format!("127.0.0.1:{port}"),
&data,
true,
Duration::from_millis(500),
);
let elapsed = t0.elapsed();
done_tx.send(()).unwrap();
server.join().unwrap();
match res {
Err(Error::Write(e)) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut, "{e}"),
other => panic!("expected a write timeout, got {other:?}"),
}
assert!(elapsed < Duration::from_secs(5), "timeout took {elapsed:?}");
}
+213
View File
@@ -0,0 +1,213 @@
"""Generate golden test data for the Rust brother-ql crate.
For every case this writes the input image(s) as 1-bit PNG plus the output of
Python ``brother_ql.conversion.convert`` (brother_ql_next 0.12) as ``.bin``
into ``crates/brother-ql/tests/golden/`` and lists the cases in
``manifest.txt``. ``tests/golden.rs`` re-runs the conversion in Rust and
requires identical bytes.
Run with a Python that has brother_ql_next, qrcode and pillow, e.g.:
C:/dev/HPCS/.venv/Scripts/python.exe -W ignore crates/brother-ql/tools/golden.py
``render_label`` below is a verbatim copy of the HPCS ``label_printer.py``
function. Pass ``--reference path/to/label_printer.py`` (or set
``LABEL_PRINTER_PY``) to import the original instead and assert both agree.
"""
import argparse
import importlib.util
import os
import random
import sys
from pathlib import Path
import qrcode
from PIL import Image, ImageDraw, ImageFont
from brother_ql.conversion import convert
from brother_ql.raster import BrotherQLRaster
OUT = Path(__file__).resolve().parent.parent / "tests" / "golden"
# ---- verbatim copy of label_printer.py (HPCS) -------------------------------
DOTS = (202, 202)
TEXT_HEIGHT = 52
def _font(size):
for name in ("arialbd.ttf", "arial.ttf", "DejaVuSans-Bold.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default(size)
def render_label(lamp_id: str) -> Image.Image:
image = Image.new("1", DOTS, 1)
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=1,
border=2,
)
qr.add_data(lamp_id.upper())
qr.make(fit=True)
modules = len(qr.get_matrix())
qr_size = DOTS[1] - TEXT_HEIGHT
qr.box_size = max(1, qr_size // modules)
qr_img = qr.make_image().get_image().convert("1")
image.paste(qr_img, ((DOTS[0] - qr_img.width) // 2, (qr_size - qr_img.height) // 2))
draw = ImageDraw.Draw(image)
size = 44
while size > 10:
font = _font(size)
box = draw.textbbox((0, 0), lamp_id, font=font)
if box[2] - box[0] <= DOTS[0] - 8:
break
size -= 2
draw.text(
((DOTS[0] - (box[2] - box[0])) // 2 - box[0], DOTS[1] - TEXT_HEIGHT + (TEXT_HEIGHT - (box[3] - box[1])) // 2 - box[1]),
lamp_id,
font=font,
fill=0,
)
return image
# -----------------------------------------------------------------------------
def random_image(w, h, seed, p=0.5):
rng = random.Random(seed)
im = Image.new("1", (w, h), 1)
px = im.load()
for y in range(h):
for x in range(w):
if rng.random() < p:
px[x, y] = 0
return im
def synthetic():
w, h = 202, 202
imgs = {
"white_202": Image.new("1", (w, h), 1),
"black_202": Image.new("1", (w, h), 0),
}
chk = Image.new("1", (w, h), 1)
px = chk.load()
for y in range(h):
for x in range(w):
if (x + y) % 2:
px[x, y] = 0
imgs["checker_202"] = chk
for name, (x, y) in {
"corner_tl": (0, 0),
"corner_tr": (w - 1, 0),
"corner_bl": (0, h - 1),
"corner_br": (w - 1, h - 1),
}.items():
im = Image.new("1", (w, h), 1)
im.putpixel((x, y), 0)
imgs[name] = im
imgs["random_202"] = random_image(w, h, 1234)
return imgs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reference", default=os.environ.get("LABEL_PRINTER_PY"))
args = ap.parse_args()
reference = None
if args.reference and Path(args.reference).exists():
spec = importlib.util.spec_from_file_location("label_printer_ref", args.reference)
reference = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reference)
OUT.mkdir(parents=True, exist_ok=True)
for f in OUT.iterdir():
if f.suffix in (".png", ".bin") or f.name == "manifest.txt":
f.unlink()
images = {}
for lamp_id in ("L0001", "L0006", "L0123", "L12345", "L9"):
im = render_label(lamp_id)
if reference is not None:
ref = reference.render_label(lamp_id)
assert ref.tobytes() == im.tobytes(), f"embedded render_label differs for {lamp_id}"
images[f"label_{lamp_id}"] = im
images.update(synthetic())
images["random_404"] = random_image(404, 404, 404)
images["random_696x300"] = random_image(696, 300, 696)
images["random_500x200"] = random_image(500, 200, 500)
images["random_300x696"] = random_image(300, 696, 300)
images["random_1392x150"] = random_image(1392, 150, 1392)
images["random_306x300"] = random_image(306, 300, 306)
images["random_271x696"] = random_image(271, 696, 2716)
images["random_236"] = random_image(236, 236, 236)
images["sparse_1164x301"] = random_image(1164, 301, 1164, p=0.01)
images["random_696x271"] = random_image(696, 271, 271)
images["sparse_696x301"] = random_image(696, 301, 301, p=0.01)
for name, im in images.items():
im.save(OUT / f"{name}.png")
cases = [] # (case name, model, label, [image names], kwargs)
base = list(images)[:13] # 5 labels + 8 synthetic 202x202 images
for name in base:
for copies in (1, 2):
cases.append((f"{name}_x{copies}", "QL-820NWB", "23x23", [name] * copies, {"cut": True}))
q = "QL-820NWB"
cases += [
("label_L0006_nocut", q, "23x23", ["label_L0006"], {"cut": False}),
("label_L0006_compress_x2", q, "23x23", ["label_L0006"] * 2, {"compress": True}),
("random_202_compress", q, "23x23", ["random_202"], {"compress": True}),
("checker_202_compress", q, "23x23", ["checker_202"], {"compress": True}),
("black_202_compress", q, "23x23", ["black_202"], {"compress": True}),
("white_202_compress", q, "23x23", ["white_202"], {"compress": True}),
("corner_tl_rot0", q, "23x23", ["corner_tl"], {"rotate": 0}),
("corner_tl_rot90", q, "23x23", ["corner_tl"], {"rotate": 90}),
("corner_tl_rot180", q, "23x23", ["corner_tl"], {"rotate": 180}),
("corner_tl_rot270", q, "23x23", ["corner_tl"], {"rotate": 270}),
("corner_br_rot0", q, "23x23", ["corner_br"], {"rotate": 0}),
("random_202_thr100", q, "23x23", ["random_202"], {"threshold": 100}),
("random_202_thr0", q, "23x23", ["random_202"], {"threshold": 0}),
("random_202_thr50", q, "23x23", ["random_202"], {"threshold": 50}),
("random_202_lq", q, "23x23", ["random_202"], {"hq": False}),
("random_202_dither", q, "23x23", ["random_202"], {"dither": True}),
("random_202_dither_thr100", q, "23x23", ["random_202"], {"dither": True, "threshold": 100}),
("multi_labels", q, "23x23", ["label_L0001", "label_L0006", "label_L12345"], {"cut": True}),
("random_404_dpi600", q, "23x23", ["random_404"], {"dpi_600": True}),
("random_404_dpi600_compress", q, "23x23", ["random_404"], {"dpi_600": True, "compress": True}),
("endless62_696", q, "62", ["random_696x300"], {}),
("endless62_resize500", q, "62", ["random_500x200"], {}),
("endless62_rot90", q, "62", ["random_300x696"], {"rotate": 90}),
("endless62_dpi600", q, "62", ["random_1392x150"], {"dpi_600": True}),
("endless29_306", q, "29", ["random_306x300"], {"compress": True}),
("diecut62x29_auto_rotate", q, "62x29", ["random_271x696"], {}),
("round_d24", q, "d24", ["random_236"], {}),
("ql1100_102", "QL-1100", "102", ["sparse_1164x301"], {"compress": True}),
("ql1100_62", "QL-1100", "62", ["sparse_696x301"], {"compress": True}),
("ql700_62_compress_ignored", "QL-700", "62", ["random_696x300"], {"compress": True}),
("ql500_29", "QL-500", "29", ["random_306x300"], {}),
("ql570_62x29", "QL-570", "62x29", ["random_696x271"], {"cut": False}),
("ql800_23x23_x2", "QL-800", "23x23", ["label_L0006"] * 2, {"compress": True}),
]
lines = ["# name model label images options... (generated by tools/golden.py)"]
for name, model, label, imgs, kwargs in cases:
inputs = [Image.open(OUT / f"{i}.png") for i in imgs]
for im in inputs:
assert im.mode == "1", (i, im.mode)
data = convert(qlr=BrotherQLRaster(model), images=inputs, label=label, **kwargs)
(OUT / f"{name}.bin").write_bytes(data)
opts = " ".join(f"{k}={v}" for k, v in kwargs.items())
lines.append(f"{name} {model} {label} {','.join(i + '.png' for i in imgs)} {opts}".rstrip())
(OUT / "manifest.txt").write_bytes(("\n".join(lines) + "\n").encode("ascii"))
total = sum(f.stat().st_size for f in OUT.iterdir())
print(f"{len(cases)} cases, {len(images)} images, {total} bytes in {OUT}")
print("reference render_label checked" if reference else "reference render_label NOT checked")
if __name__ == "__main__":
main()