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,7 @@
|
||||
# test data must stay byte-exact on every platform
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.bin binary
|
||||
*.pcap binary
|
||||
tests/golden/** -text
|
||||
crates/*/tests/golden/** -text
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "HPCS6500"]
|
||||
path = HPCS6500
|
||||
url = https://git.b4l.co.th/B4L/HPCS6500-py.git
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "lamp-testbench"
|
||||
description = "Lamp testbench around the HPCS 6500: measurement station, lamp bundles, ID labels, LED light calibration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
default-run = "lampbench"
|
||||
|
||||
[[bin]]
|
||||
name = "lampbench"
|
||||
path = "src/main.rs"
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [".", "crates/tm30", "crates/brother-ql", "crates/lamp-label"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
hpcs6500 = { path = "HPCS6500", default-features = false }
|
||||
tm30 = { path = "crates/tm30" }
|
||||
lamp-label = { path = "crates/lamp-label" }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
ctrlc = "3.4"
|
||||
serialport = "4.7"
|
||||
ureq = "3"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,162 @@
|
||||
# Lamp testbench
|
||||
|
||||
Measurement station and tools around the **HPCS 6500** spectrophotometer /
|
||||
integrating sphere, for the [buildfor.life lamp comparison](https://buildfor.life/comparisons)
|
||||
and for calibrating B4L's tunable-white LED light. Everything is Rust; one
|
||||
binary, `lampbench`.
|
||||
|
||||
| Part | Role | Where |
|
||||
|---|---|---|
|
||||
| **HPCS 6500** | spectrum, photometry, CRI, electrical, built-in AC/DC supply | driver: submodule `HPCS6500/` ([B4L/HPCS6500-py](https://git.b4l.co.th/B4L/HPCS6500-py)) |
|
||||
| **Brother QL-820NWB** | lamp ID labels, DK-11221 23x23 mm | `crates/brother-ql`, `crates/lamp-label` |
|
||||
| **TM-30-18** | Rf, Rg and the 16 hue bins from the spectrum | `crates/tm30` |
|
||||
| **comparison-data** | the published lamp bundles | `--data-repo` (git checkout) |
|
||||
|
||||
Same layout as [mppt-testbench](https://git.b4l.co.th/B4L/mppt-testbench):
|
||||
the instrument driver is its own repository, pulled in as a submodule.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://git.b4l.co.th/B4L/lamp-testbench.git
|
||||
cd lamp-testbench
|
||||
cargo build --release # target/release/lampbench(.exe)
|
||||
cargo install --path . # or put lampbench on the PATH
|
||||
```
|
||||
|
||||
The instrument is found by its USB IDs (STM32 virtual COM port, VID `0483`,
|
||||
PID `5741`); `--port COM42` picks one by hand. `hpcs6500` (the driver's CLI,
|
||||
`cargo install --path HPCS6500`) reads and controls the instrument directly.
|
||||
|
||||
## Lamp station
|
||||
|
||||
```bash
|
||||
lampbench station # scan -> measure -> commit -> push -> labels
|
||||
lampbench station --no-push # commit locally only
|
||||
lampbench station --no-commit # just write the bundle
|
||||
lampbench station --settle 0 --readings 1 # quick smoke test
|
||||
lampbench station --simulate --no-commit --data-repo D:/scratch-data # no instrument needed
|
||||
```
|
||||
|
||||
Every lamp gets an internal ID (`L0001`, `L0002`, ...) as its primary key;
|
||||
the EAN is optional metadata, so one barcode can cover daylight and warm
|
||||
white variants, and unbranded lamps work too.
|
||||
|
||||
1. Every run opens with a fresh ID; it only becomes real once a bundle is
|
||||
written, so aborting never burns a number.
|
||||
2. Scan the box's EAN (Enter skips it) or type an existing ID like `L0004` to
|
||||
re-measure that lamp with everything pre-filled (this also rewrites its
|
||||
bundle in the current format). EANs are checksum-validated and resolved to
|
||||
manufacturer / model from previous lamps, the local cache
|
||||
(`~/.cache/hpcs6500/ean-cache.json`), then upcitemdb.com; GS1-restricted
|
||||
20-29 barcodes are flagged as retailer-internal. Lamps already measured
|
||||
under the same EAN are offered for re-measurement.
|
||||
3. Confirm the identification and type the **advertised** values from the
|
||||
packaging (flux, CCT, power, CRI, lifetime, equivalent W). They are
|
||||
stored under `rated` in metrics.json for claimed-vs-measured comparison.
|
||||
4. Insert the lamp and press Enter. The procedure: 230 V / 50 Hz from the
|
||||
built-in supply, 60 s settle, 5 readings averaged (vendor single-test
|
||||
cycle per reading).
|
||||
5. `lamps/<ID>/` is written into the comparison-data checkout, committed and
|
||||
pushed; the website picks it up automatically (data-driven device list,
|
||||
auto-bump deploy). An .md page in the web repo is only needed for photos,
|
||||
buy links or notes.
|
||||
6. Two ID labels (QR code of the ID plus the ID, DK-11221) print on the
|
||||
Brother QL-820NWB: one for the box, one for the lamp. Scanning a label at
|
||||
the station types the ID and lands in the re-measure flow. Set
|
||||
`HPCS_LABEL_PRINTER=tcp://192.168.1.50` or pass `--printer`.
|
||||
|
||||
Ctrl+C during a measurement switches the supply off and skips the lamp;
|
||||
at the scan prompt, `q` or Ctrl+C quits.
|
||||
|
||||
Defaults assume `C:\dev\buildfor_life_web\apps\web\comparison-data` with the
|
||||
comparison-data submodule initialized; override with `--data-repo`.
|
||||
|
||||
## Lamp bundle export
|
||||
|
||||
```bash
|
||||
lampbench export --name philips-a60-8w --manufacturer Philips --model "A60 8W 927"
|
||||
lampbench export --name philips-a60-8w --readings 5
|
||||
lampbench export --name philips-a60-8w --voltage 230 --frequency 50 --settle 60 # power from the built-in supply
|
||||
lampbench export --name some-lamp --parse captures/run.pcap # from a USB capture
|
||||
lampbench export --name x --passive # vendor software drives the instrument
|
||||
```
|
||||
|
||||
Output lands in `lamps/<name>/` (`--out`):
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| `spd.csv` | `wavelength_nm,value`, the full 380-1050 nm spectrum |
|
||||
| `tm30.csv` | TM-30-18 per hue bin: reference/test a' b' (raw and normalized), Rcs, Rhs, Rf,h |
|
||||
| `metrics.json` | name, EAN, type, rated values, TM-30 Rf/Rg, CRI Ra + R1-R15, supply, photometric, chromaticity, radiometric, electrical, sensor |
|
||||
|
||||
The files are byte for byte what the former Python `lamp_export.py` wrote
|
||||
(tested against its output), so re-measured lamps only change where the
|
||||
measurement changed.
|
||||
|
||||
## Labels
|
||||
|
||||
```bash
|
||||
lampbench label L0006 # 2 labels on $HPCS_LABEL_PRINTER
|
||||
lampbench label L0006 --copies 1 --printer tcp://192.168.1.50
|
||||
lampbench label L0006 --preview l6.png # render only
|
||||
```
|
||||
|
||||
## TM-30
|
||||
|
||||
```bash
|
||||
lampbench tm30 lamps/L0011/spd.csv # Rf 83.2 Rg 109.3 CCT 4008 K Duv -0.0370
|
||||
lampbench tm30 lamps/L0011/spd.csv --bins # the tm30.csv rows
|
||||
```
|
||||
|
||||
`crates/tm30` implements ANSI/IES TM-30-18 (CIE 2017 colour fidelity, 99 CES,
|
||||
CAM02-UCS, Ohno 2013 CCT) in plain Rust. It reproduces colour-science 0.4.7,
|
||||
which the Python tools used, on 52 golden cases (the published lamps, CIE
|
||||
illuminants, blackbodies, odd grids): Rf and Rg agree to 1e-10, every
|
||||
output to 1e-9 relative or 1e-11 absolute. The spectrum is relative; TM-30
|
||||
is invariant to its scale.
|
||||
|
||||
> **Known issue: the spectrum's wavelength axis.** The CCT/Duv that TM-30
|
||||
> derives from the spectrum (4008 K / -0.037 for L0011) disagree with the
|
||||
> instrument's own values (4049 K / +0.0026), because the spectrum's
|
||||
> chromaticity on the assumed linear 380-1050 nm axis is 0.09-0.13 (xy) off
|
||||
> the instrument's x, y. The published Rf/Rg inherit this. Details and how
|
||||
> to confirm the axis: `HPCS6500/PROTOCOL.md`, "Spectral Data".
|
||||
|
||||
## LED light calibration
|
||||
|
||||
Calibrates the mixing table of the B4L tunable-white light (2700 K + 5000 K
|
||||
LEDs) over its UART, with the HPCS 6500 as the meter:
|
||||
|
||||
```bash
|
||||
lampbench calibrate COM3 # y-data, then up to 10 mixing-ratio passes until all 45 points are within 5 %
|
||||
lampbench calibrate COM3 --skip-y # mixing ratios only
|
||||
lampbench calibrate COM3 --y-only # LED curves only
|
||||
lampbench calibrate COM3 --read # show the table in the light
|
||||
lampbench calibrate COM3 --clear # back to firmware defaults
|
||||
lampbench calibrate COM3 --generate-only # table from calibration_table.json
|
||||
lampbench calibrate COM3 --write # ... and write it to the light
|
||||
```
|
||||
|
||||
Progress and results go to `calibration_table.json` (`--output`), in the
|
||||
format of the former `calibration.py`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo test --workspace # everything, including the driver submodule
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
- `tests/golden/bundles/` are bundles written by the Python `lamp_export.py`
|
||||
from real readings (the driver's `tests/fixtures/`); `bundle.rs` must
|
||||
reproduce them byte for byte.
|
||||
- `tests/station.rs` runs the whole station with scripted answers against the
|
||||
simulated instrument and a throwaway comparison-data repository.
|
||||
- The calibration is tested against a simulated light whose CCT follows its
|
||||
mixing ratio.
|
||||
- `crates/tm30/tests`, `crates/brother-ql/tests`, `crates/lamp-label/tests`
|
||||
compare against the Python libraries (colour-science, brother_ql, qrcode);
|
||||
`tools/` in each crate regenerates the golden data.
|
||||
|
||||
The Python tools these replace are in HPCS6500-py at tag `python-final`.
|
||||
@@ -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 |