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.
No files matched your search
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 B |
|
After Width: | Height: | Size: 136 B |
|
After Width: | Height: | Size: 129 B |
|
After Width: | Height: | Size: 128 B |
|
After Width: | Height: | Size: 131 B |
|
After Width: | Height: | Size: 129 B |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 614 B |
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 506 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 126 B |
@@ -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:?}");
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "lamp-label"
|
||||
description = "Lamp ID labels (QR code + text) for the Brother QL-820NWB on DK-11221 23x23 mm labels"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "lamp_label"
|
||||
|
||||
[dependencies]
|
||||
brother-ql = { path = "../brother-ql" }
|
||||
fontdue = "0.9.4"
|
||||
png = "0.17"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Tiny embedded 5x7 bitmap font, used only when no TrueType font file is
|
||||
//! available so that printing never fails because of fonts.
|
||||
|
||||
/// Glyph rows top to bottom; bit 4 is the leftmost column.
|
||||
pub(crate) fn glyph(c: char) -> [u8; 7] {
|
||||
match c.to_ascii_uppercase() {
|
||||
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
|
||||
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
||||
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
|
||||
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
|
||||
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
|
||||
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
|
||||
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
|
||||
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
|
||||
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
|
||||
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
|
||||
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
||||
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
|
||||
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
|
||||
'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
|
||||
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
|
||||
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
|
||||
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
|
||||
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
||||
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
||||
'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
|
||||
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
|
||||
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
|
||||
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
|
||||
'N' => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
|
||||
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
||||
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
|
||||
'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
|
||||
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
|
||||
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
|
||||
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
|
||||
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
||||
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
|
||||
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
|
||||
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
|
||||
'Y' => [0x11, 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04],
|
||||
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
|
||||
'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
|
||||
'_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
|
||||
'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C],
|
||||
':' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00],
|
||||
'/' => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10],
|
||||
' ' => [0; 7],
|
||||
// anything else: an empty box (like a .notdef glyph)
|
||||
_ => [0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F],
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph cell: 5 columns + 1 column spacing, 7 rows.
|
||||
pub(crate) const COLS: u32 = 5;
|
||||
pub(crate) const ADVANCE: u32 = 6;
|
||||
pub(crate) const ROWS: u32 = 7;
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Lamp ID labels for the Brother QL-820NWB on DK-11221 (23 x 23 mm die-cut)
|
||||
//! labels: a Rust port of the HPCS `label_printer.py`.
|
||||
//!
|
||||
//! Each 202 x 202 dot label carries the lamp ID as a QR code (top 150 dots)
|
||||
//! and as text (bottom 52 dots). Scanning the QR code types the ID.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! let label = lamp_label::render_label("L0006");
|
||||
//! lamp_label::save_png(&label, std::path::Path::new("L0006.png"))?;
|
||||
//! lamp_label::print_labels("L0006", "tcp://192.168.1.50", 2)?;
|
||||
//! # Ok::<(), lamp_label::Error>(())
|
||||
//! ```
|
||||
|
||||
mod font5x7;
|
||||
pub mod qr;
|
||||
pub mod text;
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use brother_ql::Bitmap;
|
||||
use brother_ql::{ConvertOptions, DEFAULT_TIMEOUT, LABEL_23X23, QL_820NWB};
|
||||
pub use text::{LabelFont, TextLayout};
|
||||
|
||||
/// Printable area of the 23x23 label in dots (width, height).
|
||||
pub const DOTS: (u32, u32) = (202, 202);
|
||||
/// Height of the bottom strip holding the ID text.
|
||||
pub const TEXT_HEIGHT: u32 = 52;
|
||||
/// brother_ql model identifier of the label printer.
|
||||
pub const MODEL: &str = "QL-820NWB";
|
||||
/// brother_ql label identifier (DK-11221).
|
||||
pub const LABEL_ID: &str = "23x23";
|
||||
/// Labels printed per lamp by default (one for the box, one for the lamp).
|
||||
pub const DEFAULT_COPIES: u32 = 2;
|
||||
|
||||
/// Errors of this crate.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// Conversion or sending failed.
|
||||
Printer(brother_ql::Error),
|
||||
/// The ID is too long for a version 40 QR code.
|
||||
QrDataTooLong(usize),
|
||||
/// A font file could not be parsed.
|
||||
Font(String),
|
||||
/// File I/O failed.
|
||||
Io(std::io::Error),
|
||||
/// PNG encoding failed.
|
||||
Png(png::EncodingError),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Printer(e) => write!(f, "label printer: {e}"),
|
||||
Error::QrDataTooLong(n) => write!(f, "lamp ID of {n} bytes does not fit in a QR code"),
|
||||
Error::Font(e) => write!(f, "font: {e}"),
|
||||
Error::Io(e) => write!(f, "I/O: {e}"),
|
||||
Error::Png(e) => write!(f, "PNG: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Error::Printer(e) => Some(e),
|
||||
Error::Io(e) => Some(e),
|
||||
Error::Png(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<brother_ql::Error> for Error {
|
||||
fn from(e: brother_ql::Error) -> Self {
|
||||
Error::Printer(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Error::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<png::EncodingError> for Error {
|
||||
fn from(e: png::EncodingError) -> Self {
|
||||
Error::Png(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// The font used by [`render_label`]: [`LabelFont::system`], loaded once.
|
||||
pub fn default_font() -> &'static LabelFont {
|
||||
static FONT: OnceLock<LabelFont> = OnceLock::new();
|
||||
FONT.get_or_init(LabelFont::system)
|
||||
}
|
||||
|
||||
/// Renders the 202x202 1-bit label for `lamp_id` with the default font.
|
||||
///
|
||||
/// QR code of the upper-cased ID (level M, quiet zone 2, module size
|
||||
/// `max(1, 150 // (modules + 4))`) centred in the top 150 dots; the ID as
|
||||
/// given, in the largest font size from 44 down (step 2) whose text box is at
|
||||
/// most 194 dots wide, centred in the bottom 52 dots.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the ID is too long for any QR version (thousands of characters); use
|
||||
/// [`try_render_label`] to get an error instead.
|
||||
pub fn render_label(lamp_id: &str) -> Bitmap {
|
||||
try_render_label(lamp_id).expect("lamp ID too long for a QR code")
|
||||
}
|
||||
|
||||
/// Like [`render_label`] but returns an error for IDs that do not fit a QR code.
|
||||
pub fn try_render_label(lamp_id: &str) -> Result<Bitmap, Error> {
|
||||
render_label_with_font(lamp_id, default_font()).map(|(b, _)| b)
|
||||
}
|
||||
|
||||
/// Renders the label with a specific font, also returning the text layout.
|
||||
pub fn render_label_with_font(
|
||||
lamp_id: &str,
|
||||
font: &LabelFont,
|
||||
) -> Result<(Bitmap, TextLayout), Error> {
|
||||
let (w, h) = DOTS;
|
||||
let mut image = Bitmap::new(w, h);
|
||||
|
||||
let upper = lamp_id.to_uppercase();
|
||||
let qr = qr::encode(&upper).map_err(|_| Error::QrDataTooLong(upper.len()))?;
|
||||
let border = 2;
|
||||
let modules = qr.size as u32 + 2 * border;
|
||||
let qr_size = h - TEXT_HEIGHT;
|
||||
let box_size = (qr_size / modules).max(1);
|
||||
let img_size = modules * box_size;
|
||||
let x0 = (i64::from(w) - i64::from(img_size)).div_euclid(2);
|
||||
let y0 = (i64::from(qr_size) - i64::from(img_size)).div_euclid(2);
|
||||
// the QR image (white quiet zone included) is pasted over the white label
|
||||
image.fill_rect(x0, y0, img_size, img_size, false);
|
||||
for r in 0..qr.size {
|
||||
for c in 0..qr.size {
|
||||
if qr.get(c, r) {
|
||||
image.fill_rect(
|
||||
x0 + i64::from((c as u32 + border) * box_size),
|
||||
y0 + i64::from((r as u32 + border) * box_size),
|
||||
box_size,
|
||||
box_size,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let layout = text::draw_text(
|
||||
&mut image,
|
||||
font,
|
||||
lamp_id,
|
||||
(h - TEXT_HEIGHT) as i32,
|
||||
TEXT_HEIGHT as i32,
|
||||
);
|
||||
Ok((image, layout))
|
||||
}
|
||||
|
||||
/// The raster instructions `print_labels` sends: `copies` labels for the
|
||||
/// QL-820NWB on 23x23 labels with cutting enabled (Python:
|
||||
/// `convert(BrotherQLRaster('QL-820NWB'), [image] * copies, '23x23', cut=True)`).
|
||||
pub fn label_instructions(lamp_id: &str, copies: u32) -> Result<Vec<u8>, Error> {
|
||||
let image = try_render_label(lamp_id)?;
|
||||
let images = vec![image; copies as usize];
|
||||
let opts = ConvertOptions {
|
||||
cut: true,
|
||||
..ConvertOptions::default()
|
||||
};
|
||||
Ok(brother_ql::convert(
|
||||
&QL_820NWB,
|
||||
&LABEL_23X23,
|
||||
&images,
|
||||
&opts,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Renders and prints `copies` labels on the QL-820NWB at `printer`
|
||||
/// (`tcp://host[:port]` or `host`, port 9100 by default). Returns once the
|
||||
/// job is written to the printer's socket; like the Python version it does
|
||||
/// not wait for a print confirmation (the network backend has no read-back).
|
||||
pub fn print_labels(lamp_id: &str, printer: &str, copies: u32) -> Result<(), Error> {
|
||||
let data = label_instructions(lamp_id, copies)?;
|
||||
brother_ql::send_network(printer, &data, true, DEFAULT_TIMEOUT)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a bitmap as a 1-bit grayscale PNG (white = 1, like PIL mode "1").
|
||||
pub fn save_png(bitmap: &Bitmap, path: &Path) -> Result<(), Error> {
|
||||
let file = BufWriter::new(File::create(path)?);
|
||||
let mut enc = png::Encoder::new(file, bitmap.width(), bitmap.height());
|
||||
enc.set_color(png::ColorType::Grayscale);
|
||||
enc.set_depth(png::BitDepth::One);
|
||||
let mut writer = enc.write_header()?;
|
||||
let stride = bitmap.width().div_ceil(8) as usize;
|
||||
let mut data = vec![0u8; stride * bitmap.height() as usize];
|
||||
for y in 0..bitmap.height() {
|
||||
for (x, &black) in bitmap.row(y).iter().enumerate() {
|
||||
if !black {
|
||||
data[y as usize * stride + x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
writer.write_image_data(&data)?;
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
}
|
||||