Lamp testbench in Rust around the HPCS 6500

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

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

No files matched your search

+15
View File
@@ -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 }
+57
View File
@@ -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;
+211
View File
@@ -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(())
}
+379
View File
@@ -0,0 +1,379 @@
//! QR encoding that reproduces Python `qrcode` 8.2 exactly.
//!
//! The `qrcode` crate supplies bit packing, Reed-Solomon codewords, module
//! placement and masking. Two decisions are made here the Python way so the
//! module matrices are identical:
//!
//! * **segmentation**: `qrcode.util.optimal_data_chunks(data, minimum=20)`
//! (`QRCode.add_data` default `optimize=20`), not the crate's optimiser;
//! * **mask choice**: `QRCode.best_mask_pattern`, i.e. `util.lost_point`
//! evaluated on matrices whose format/version information is all light
//! ("test" mode), first minimum wins.
use qrcode::bits::Bits;
use qrcode::canvas::{Canvas, MaskPattern};
use qrcode::ec::construct_codewords;
use qrcode::{Color, EcLevel, Version};
/// QR data segment mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Digits only.
Numeric,
/// `0-9 A-Z $%*+-./:` and space.
Alphanumeric,
/// 8-bit bytes (UTF-8 of the input).
Byte,
}
/// A data segment with its mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Segment<'a> {
/// Encoding mode.
pub mode: Mode,
/// The bytes of this segment.
pub data: &'a [u8],
}
const ALPHA_NUM: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
fn is_digit(b: u8) -> bool {
b.is_ascii_digit()
}
fn is_alnum(b: u8) -> bool {
ALPHA_NUM.contains(&b)
}
/// One `re.search` step of `_optimal_split`: the first match `(start, end)`.
fn search(
data: &[u8],
class: fn(u8) -> bool,
whole: bool,
minimum: usize,
) -> Option<(usize, usize)> {
if whole {
// ^X+$ ; `$` also matches right before a single trailing newline
let all = |s: &[u8]| !s.is_empty() && s.iter().all(|&b| class(b));
if all(data) {
return Some((0, data.len()));
}
return match data.strip_suffix(b"\n") {
Some(body) if all(body) => Some((0, body.len())),
_ => None,
};
}
// X{minimum,}: leftmost start of a run of at least `minimum`, greedy
let mut i = 0;
while i < data.len() {
if class(data[i]) {
let start = i;
while i < data.len() && class(data[i]) {
i += 1;
}
if i - start >= minimum {
return Some((start, i));
}
} else {
i += 1;
}
}
None
}
/// `_optimal_split`: alternating (matched, chunk) pieces.
fn optimal_split(
mut data: &[u8],
class: fn(u8) -> bool,
whole: bool,
minimum: usize,
) -> Vec<(bool, &[u8])> {
let mut out = Vec::new();
while !data.is_empty() {
let Some((start, end)) = search(data, class, whole, minimum) else {
break;
};
if start > 0 {
out.push((false, &data[..start]));
}
out.push((true, &data[start..end]));
data = &data[end..];
}
if !data.is_empty() {
out.push((false, data));
}
out
}
/// Port of `qrcode.util.optimal_data_chunks(data, minimum)`.
pub fn optimal_segments(data: &[u8], minimum: usize) -> Vec<Segment<'_>> {
let whole = data.len() <= minimum;
let mut out = Vec::new();
for (is_num, chunk) in optimal_split(data, is_digit, whole, minimum) {
if is_num {
out.push(Segment {
mode: Mode::Numeric,
data: chunk,
});
} else {
for (is_alpha, sub) in optimal_split(chunk, is_alnum, whole, minimum) {
out.push(Segment {
mode: if is_alpha {
Mode::Alphanumeric
} else {
Mode::Byte
},
data: sub,
});
}
}
}
out
}
/// A QR symbol: modules without quiet zone, `true` = dark.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QrMatrix {
/// QR version (1-40).
pub version: u8,
/// Mask pattern number (0-7) chosen like Python qrcode.
pub mask: u8,
/// Modules per side (`4 * version + 17`).
pub size: usize,
/// Row-major modules, `true` = dark.
pub modules: Vec<bool>,
}
impl QrMatrix {
/// Module at column `x`, row `y`.
pub fn get(&self, x: usize, y: usize) -> bool {
self.modules[y * self.size + x]
}
}
/// The input does not fit in a version 40 QR code at level M.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DataTooLong;
const PATTERNS: [MaskPattern; 8] = [
MaskPattern::Checkerboard,
MaskPattern::HorizontalLines,
MaskPattern::VerticalLines,
MaskPattern::DiagonalLines,
MaskPattern::LargeCheckerboard,
MaskPattern::Fields,
MaskPattern::Diamonds,
MaskPattern::Meadow,
];
fn build_bits(segments: &[Segment<'_>], version: i16) -> Option<Bits> {
let mut bits = Bits::new(Version::Normal(version));
for s in segments {
let r = match s.mode {
Mode::Numeric => bits.push_numeric_data(s.data),
Mode::Alphanumeric => bits.push_alphanumeric_data(s.data),
Mode::Byte => bits.push_byte_data(s.data),
};
r.ok()?;
}
bits.push_terminator(EcLevel::M).ok()?;
Some(bits)
}
/// Encodes `data` exactly like
/// `qrcode.QRCode(error_correction=ERROR_CORRECT_M); add_data(data); make(fit=True)`.
pub fn encode(data: &str) -> Result<QrMatrix, DataTooLong> {
let segments = optimal_segments(data.as_bytes(), 20);
let (version, bits) = (1..=40)
.find_map(|v| build_bits(&segments, v).map(|b| (v, b)))
.ok_or(DataTooLong)?;
let qr_version = Version::Normal(version);
let raw = bits.into_bytes();
let (codewords, ec) =
construct_codewords(&raw, qr_version, EcLevel::M).map_err(|_| DataTooLong)?;
let mut canvas = Canvas::new(qr_version, EcLevel::M);
canvas.draw_all_functional_patterns();
canvas.draw_data(&codewords, &ec);
let n = qr_version.width() as usize;
let mut best = 0usize;
let mut best_lost = 0u64;
for (i, &p) in PATTERNS.iter().enumerate() {
let mut c = canvas.clone();
c.apply_mask(p);
let mut m = to_bools(c, n);
clear_type_info(&mut m, n, version);
let lost = lost_point(&m, n);
if i == 0 || best_lost > lost {
best_lost = lost;
best = i;
}
}
canvas.apply_mask(PATTERNS[best]);
Ok(QrMatrix {
version: version as u8,
mask: best as u8,
size: n,
modules: to_bools(canvas, n),
})
}
fn to_bools(c: Canvas, n: usize) -> Vec<bool> {
let v: Vec<bool> = c
.into_colors()
.into_iter()
.map(|c| c == Color::Dark)
.collect();
debug_assert_eq!(v.len(), n * n);
v
}
/// Python evaluates masks with `makeImpl(test=True)`: format information,
/// the dark module and (version >= 7) version information are all light.
fn clear_type_info(m: &mut [bool], n: usize, version: i16) {
let mut clear = |r: usize, c: usize| m[r * n + c] = false;
for i in 0..15 {
// vertical
let r = if i < 6 {
i
} else if i < 8 {
i + 1
} else {
n - 15 + i
};
clear(r, 8);
// horizontal
let c = if i < 8 {
n - i - 1
} else if i < 9 {
15 - i
} else {
15 - i - 1
};
clear(8, c);
}
clear(n - 8, 8);
if version >= 7 {
for i in 0..18 {
clear(i / 3, i % 3 + n - 11);
clear(i % 3 + n - 11, i / 3);
}
}
}
/// Port of `qrcode.util.lost_point` (penalty score used for the mask choice).
pub fn lost_point(m: &[bool], n: usize) -> u64 {
let at = |r: usize, c: usize| m[r * n + c];
let mut lost = 0u64;
// level 1: runs of >= 5 same-coloured modules in rows and columns
let runs = |get: &dyn Fn(usize, usize) -> bool| {
let mut sum = 0u64;
for a in 0..n {
let mut prev = get(a, 0);
let mut len = 0u64;
for b in 0..n {
let v = get(a, b);
if v == prev {
len += 1;
} else {
if len >= 5 {
sum += len - 2;
}
len = 1;
prev = v;
}
}
if len >= 5 {
sum += len - 2;
}
}
sum
};
lost += runs(&|r, c| at(r, c));
lost += runs(&|c, r| at(r, c));
// level 2: 2x2 blocks of one colour
for r in 0..n - 1 {
for c in 0..n - 1 {
let v = at(r, c);
if v == at(r, c + 1) && v == at(r + 1, c) && v == at(r + 1, c + 1) {
lost += 3;
}
}
}
// level 3: 1:1:3:1:1 finder-like patterns with 4 light modules on one side
const P1: [bool; 11] = [
true, false, true, true, true, false, true, false, false, false, false,
];
const P2: [bool; 11] = [
false, false, false, false, true, false, true, true, true, false, true,
];
if n >= 11 {
for a in 0..n {
for b in 0..=n - 11 {
let row_hit = |p: &[bool; 11]| (0..11).all(|k| at(a, b + k) == p[k]);
if row_hit(&P1) || row_hit(&P2) {
lost += 40;
}
let col_hit = |p: &[bool; 11]| (0..11).all(|k| at(b + k, a) == p[k]);
if col_hit(&P1) || col_hit(&P2) {
lost += 40;
}
}
}
}
// level 4: dark ratio, 10 points per 5 % away from 50 %
let dark = m.iter().filter(|&&d| d).count() as f64;
let percent = dark / (n * n) as f64;
let rating = ((percent * 100.0 - 50.0).abs() / 5.0) as u64;
lost + rating * 10
}
#[cfg(test)]
mod tests {
use super::*;
fn modes(data: &str) -> Vec<(Mode, String)> {
optimal_segments(data.as_bytes(), 20)
.into_iter()
.map(|s| (s.mode, String::from_utf8_lossy(s.data).into_owned()))
.collect()
}
#[test]
fn segmentation_like_python() {
assert_eq!(modes("L0006"), [(Mode::Alphanumeric, "L0006".into())]);
assert_eq!(modes("12345"), [(Mode::Numeric, "12345".into())]);
assert_eq!(modes("LAMP_7"), [(Mode::Byte, "LAMP_7".into())]);
assert_eq!(
modes("123\n"),
[(Mode::Numeric, "123".into()), (Mode::Byte, "\n".into())]
);
let long = format!("L{}", "0123456789".repeat(3));
assert_eq!(
modes(&long),
[
(Mode::Byte, "L".into()),
(Mode::Numeric, "0123456789".repeat(3))
]
);
let url = "HTTPS://EXAMPLE.COM/LAMP/L0006?X=1";
assert_eq!(
modes(url),
[
(Mode::Alphanumeric, "HTTPS://EXAMPLE.COM/LAMP/L0006".into()),
(Mode::Byte, "?X=1".into())
]
);
}
#[test]
fn version_1_for_lamp_ids() {
let q = encode("L0006").unwrap();
assert_eq!((q.version, q.size), (1, 21));
assert!(encode(&"9".repeat(8000)).is_err());
}
}
+523
View File
@@ -0,0 +1,523 @@
//! Text rendering for the label's ID strip, emulating Pillow's
//! `ImageDraw.text` on a mode `"1"` image (FreeType monochrome rendering with
//! raqm layout) as closely as practical with `fontdue`:
//!
//! * **layout** like Pillow + raqm: unhinted advances and kerning rounded to
//! 1/64 px, glyph origins at the pen position rounded to whole pixels
//! (Pillow's `PIXEL()`), text bbox for the `"la"` anchor (left / ascender
//! line) that always includes the pen origin and the advance end, built from
//! pixel-aligned glyph boxes; ascender = `ceil(hhea ascender)` (FreeType);
//! * **glyphs**: outlines are point-sampled at pixel centres (8x
//! supersampled coverage >= 50 %), i.e. 1-bit without anti-aliasing like
//! FreeType's mono renderer. TrueType hinting is approximated vertically
//! only: the cap height is snapped to `round(cap + 0.23 px)` (matches
//! Arial Bold's hints at every size 10-60 px) and overshoots below 0.555 px
//! are flattened. Horizontal stem snapping is not emulated, so stems and
//! bowls can differ by a pixel from Pillow's output.
//!
//! If no font file can be loaded, a built-in 5x7 bitmap font scaled to the
//! requested size is used instead.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use brother_ql::Bitmap;
use crate::Error;
use crate::font5x7;
/// Supersampling factor for outline point sampling.
const SS: f64 = 8.0;
/// Cap-height snapping bias in px (see `TrueType::warp`).
const CAP_BIAS: f64 = 0.23;
/// Overshoots smaller than this (px) are suppressed.
const OVERSHOOT_LIMIT: f64 = 0.555;
/// Font files tried in order by [`LabelFont::system`] (after `$LAMP_LABEL_FONT`
/// and `%WINDIR%\Fonts`). Mirrors the Python `_font()` preference:
/// Arial Bold, Arial, DejaVu Sans Bold (plus Liberation Sans Bold).
pub const FONT_CANDIDATES: &[&str] = &[
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/arial.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Arial_Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",
"/usr/share/fonts/TTF/LiberationSans-Bold.ttf",
];
/// The font used for the ID text: a TrueType file or the built-in bitmap font.
pub struct LabelFont {
kind: Kind,
name: String,
}
enum Kind {
TrueType(Box<TrueType>),
Builtin,
}
struct TrueType {
font: fontdue::Font,
upem: f64,
ascender: f64,
/// Cap height (top of 'H') in font units.
cap: Option<f64>,
/// Overshoot of 'O' above the cap height / below the baseline, font units.
overshoot_top: f64,
overshoot_bottom: f64,
}
impl std::fmt::Debug for LabelFont {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LabelFont")
.field("name", &self.name)
.finish()
}
}
fn font_paths() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(p) = std::env::var_os("LAMP_LABEL_FONT") {
v.push(PathBuf::from(p));
}
if let Some(w) = std::env::var_os("WINDIR") {
let fonts = Path::new(&w).join("Fonts");
v.push(fonts.join("arialbd.ttf"));
v.push(fonts.join("arial.ttf"));
}
v.extend(FONT_CANDIDATES.iter().map(PathBuf::from));
v
}
impl LabelFont {
/// The first loadable font of `$LAMP_LABEL_FONT`, `%WINDIR%\Fonts\arialbd.ttf`,
/// `arial.ttf` and [`FONT_CANDIDATES`]; the built-in bitmap font if none loads.
pub fn system() -> LabelFont {
font_paths()
.iter()
.find_map(|p| LabelFont::from_file(p).ok())
.unwrap_or_else(LabelFont::builtin)
}
/// Loads a TrueType/OpenType font file.
pub fn from_file(path: &Path) -> Result<LabelFont, Error> {
let bytes = std::fs::read(path)?;
let name = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
LabelFont::from_bytes(bytes, &name)
}
/// Parses font data; `name` is only used for reporting.
pub fn from_bytes(bytes: Vec<u8>, name: &str) -> Result<LabelFont, Error> {
let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())
.map_err(|e| Error::Font(format!("{name}: {e}")))?;
let upem = f64::from(font.units_per_em());
let units = upem as f32;
let ascender = font
.horizontal_line_metrics(units)
.map_or(upem * 0.9, |m| f64::from(m.ascent));
let top = |c: char| {
font.has_glyph(c).then(|| {
let b = font.metrics(c, units).bounds;
(f64::from(b.ymin), f64::from(b.ymin + b.height))
})
};
let cap = top('H').map(|(_, t)| t).filter(|&t| t > 0.0);
let (overshoot_top, overshoot_bottom) = match (cap, top('O')) {
(Some(cap), Some((b, t))) => ((t - cap).max(0.0), (-b).max(0.0)),
_ => (0.0, 0.0),
};
Ok(LabelFont {
kind: Kind::TrueType(Box::new(TrueType {
font,
upem,
ascender,
cap,
overshoot_top,
overshoot_bottom,
})),
name: name.to_string(),
})
}
/// The built-in 5x7 bitmap font (digits, A-Z, `- _ . : /`).
pub fn builtin() -> LabelFont {
LabelFont {
kind: Kind::Builtin,
name: "builtin-5x7".to_string(),
}
}
/// File name of the font, or `"builtin-5x7"`.
pub fn name(&self) -> &str {
&self.name
}
/// `true` for the built-in bitmap font.
pub fn is_builtin(&self) -> bool {
matches!(self.kind, Kind::Builtin)
}
}
/// Where and how big the ID text is drawn (values as Pillow reports them).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextLayout {
/// Font name (file name or `builtin-5x7`).
pub font: String,
/// Chosen font size in pixels per em.
pub size: u32,
/// `ImageDraw.textbbox((0, 0), text)` at that size: (x0, y0, x1, y1).
pub bbox: (i32, i32, i32, i32),
/// The `xy` passed to `ImageDraw.text` (anchor "la").
pub origin: (i32, i32),
}
/// Vertical "hinting": maps unhinted heights (px above baseline) to snapped
/// ones. The cap height is stretched to a whole pixel; overshoots (round
/// glyphs poking up to one pixel past the baseline or cap height) are
/// flattened when the font's overshoot is below [`OVERSHOOT_LIMIT`] at this size.
struct Warp {
k: f64,
cap_u: f64,
cap_s: f64,
suppress_top: bool,
suppress_bottom: bool,
}
impl Warp {
fn identity() -> Warp {
Warp {
k: 1.0,
cap_u: f64::INFINITY,
cap_s: f64::INFINITY,
suppress_top: false,
suppress_bottom: false,
}
}
/// Snapped height -> unhinted height (used for sampling pixel centres).
fn inv(&self, h: f64) -> f64 {
if h < 0.0 {
if self.suppress_bottom && h > -1.0 {
h - 1.0
} else {
h
}
} else if h <= self.cap_s {
h / self.k
} else if self.suppress_top && h < self.cap_s + 1.0 {
h - self.cap_s + self.cap_u + 1.0
} else {
h - self.cap_s + self.cap_u
}
}
/// Unhinted height -> snapped height (used for the glyph boxes).
fn fwd(&self, y: f64) -> f64 {
if y < 0.0 {
if self.suppress_bottom && y > -1.0 {
0.0
} else {
y
}
} else if y <= self.cap_u {
y * self.k
} else if self.suppress_top && y < self.cap_u + 1.0 {
self.cap_s
} else {
y - self.cap_u + self.cap_s
}
}
}
/// 1-bit glyph image in pen-relative pixel coordinates.
#[derive(Default, Clone)]
struct GlyphInk {
/// Columns `x0..x1` relative to the integer pen position.
x0: i32,
x1: i32,
/// Height bands `j0..j1` (band `j` covers `[j, j+1]` px above the baseline).
j0: i32,
j1: i32,
/// Row-major, first row is band `j1 - 1`.
ink: Vec<bool>,
/// Pixel-aligned outline box (x0, y0, x1, y1), y up from the baseline,
/// like `FT_Glyph_Get_CBox(FT_GLYPH_BBOX_PIXELS)`; `None` for empty glyphs.
cbox: Option<(i32, i32, i32, i32)>,
}
impl GlyphInk {
fn is_empty(&self) -> bool {
self.x0 >= self.x1 || self.j0 >= self.j1
}
}
/// Pillow `PIXEL()`: 26.6 -> whole pixels, rounding half up.
fn pixel(v: i64) -> i32 {
(v + 32).div_euclid(64) as i32
}
impl TrueType {
fn to26(&self, units: f64, size: f64) -> i64 {
(units * size * 64.0 / self.upem).round() as i64
}
fn warp(&self, size: f64) -> Warp {
let Some(cap) = self.cap else {
return Warp::identity();
};
let cap_u = cap * size / self.upem;
// Arial Bold's hints put the cap height at round(cap + 0.23 px) for
// every size from 10 to 60 px (FreeType 2.14 mono); used for all fonts.
let cap_s = (cap_u + CAP_BIAS + 0.5).floor();
if cap_s <= 0.0 {
return Warp::identity();
}
// overshoots below ~0.55 px are flattened by the hints (45 px: yes, 46 px: no)
let suppressed = |units: f64| units * size / self.upem < OVERSHOOT_LIMIT;
Warp {
k: cap_s / cap_u,
cap_u,
cap_s,
suppress_top: suppressed(self.overshoot_top),
suppress_bottom: suppressed(self.overshoot_bottom),
}
}
fn glyph_ink(&self, index: u16, size: f64, warp: &Warp) -> GlyphInk {
let (m, cov) = self.font.rasterize_indexed(index, (size * SS) as f32);
if m.width == 0 || m.height == 0 {
return GlyphInk::default();
}
let (w, h) = (m.width as i64, m.height as i64);
let (hx0, hy0) = (i64::from(m.xmin), i64::from(m.ymin));
let inside = |x: f64, y: f64| {
let col = (x * SS).floor() as i64 - hx0;
let row = (hy0 + h - 1) - (y * SS).floor() as i64;
(0..w).contains(&col) && (0..h).contains(&row) && cov[(row * w + col) as usize] >= 128
};
let cx0 = (hx0 as f64 / SS).floor() as i32 - 1;
let cx1 = ((hx0 + w) as f64 / SS).ceil() as i32 + 1;
let j0 = warp.fwd(hy0 as f64 / SS).floor() as i32 - 2;
let j1 = warp.fwd((hy0 + h) as f64 / SS).ceil() as i32 + 2;
let mut pts = Vec::new();
for j in j0..j1 {
let y = warp.inv(f64::from(j) + 0.5);
for c in cx0..cx1 {
if inside(f64::from(c) + 0.5, y) {
pts.push((c, j));
}
}
}
if pts.is_empty() {
return GlyphInk::default();
}
let x0 = pts.iter().map(|p| p.0).min().unwrap();
let x1 = pts.iter().map(|p| p.0).max().unwrap() + 1;
let j0 = pts.iter().map(|p| p.1).min().unwrap();
let j1 = pts.iter().map(|p| p.1).max().unwrap() + 1;
let width = (x1 - x0) as usize;
let mut ink = vec![false; width * (j1 - j0) as usize];
for (c, j) in pts {
ink[(j1 - 1 - j) as usize * width + (c - x0) as usize] = true;
}
let b = m.bounds;
let (bx0, by0) = (f64::from(b.xmin) / SS, f64::from(b.ymin) / SS);
let (bx1, by1) = (
bx0 + f64::from(b.width) / SS,
by0 + f64::from(b.height) / SS,
);
const EPS: f64 = 1e-3;
let cbox = Some((
(bx0 + EPS).floor() as i32,
(warp.fwd(by0) + EPS).floor() as i32,
(bx1 - EPS).ceil() as i32,
(warp.fwd(by1) - EPS).ceil() as i32,
));
GlyphInk {
x0,
x1,
j0,
j1,
ink,
cbox,
}
}
}
/// A laid-out line of text at one size.
struct Shaped {
/// (pen x in px, glyph ink)
glyphs: Vec<(i32, GlyphInk)>,
bbox: (i32, i32, i32, i32),
ascender: i32,
}
fn shape_truetype(tt: &TrueType, text: &str, size: u32) -> Shaped {
let s = f64::from(size);
let units = tt.upem as f32;
let warp = tt.warp(s);
let indices: Vec<u16> = text
.chars()
.map(|c| tt.font.lookup_glyph_index(c))
.collect();
// raqm/HarfBuzz: unhinted advances, pair kerning added to the first glyph
let advances: Vec<i64> = indices
.iter()
.enumerate()
.map(|(i, &g)| {
let adv = f64::from(tt.font.metrics_indexed(g, units).advance_width);
let kern = indices
.get(i + 1)
.and_then(|&n| tt.font.horizontal_kern_indexed(g, n, units))
.map_or(0, |k| tt.to26(f64::from(k), s));
tt.to26(adv, s) + kern
})
.collect();
let mut cache: HashMap<u16, GlyphInk> = HashMap::new();
let (mut x_min, mut x_max, mut y_min, mut y_max) = (0i32, 0i32, 0i32, 0i32);
let mut position = 0i64;
let mut glyphs = Vec::with_capacity(indices.len());
for (&g, &adv) in indices.iter().zip(&advances) {
let px = pixel(position);
position += adv;
x_max = x_max.max(pixel(position));
let ink = cache
.entry(g)
.or_insert_with(|| tt.glyph_ink(g, s, &warp))
.clone();
if let Some((bx0, by0, bx1, by1)) = ink.cbox {
x_min = x_min.min(px + bx0);
x_max = x_max.max(px + bx1);
y_min = y_min.min(by0);
y_max = y_max.max(by1);
}
glyphs.push((px, ink));
}
let ascender = (tt.ascender * s / tt.upem - 1e-9).ceil() as i32; // FreeType FT_PIX_CEIL
Shaped {
glyphs,
bbox: (x_min, ascender - y_max, x_max, ascender - y_min),
ascender,
}
}
/// Integer scale of the 5x7 font for a nominal size (cap height ~0.716 em, like Arial).
fn builtin_scale(size: u32) -> u32 {
let cap = (f64::from(size) * 0.716).round() as u32;
(cap / font5x7::ROWS).max(1)
}
fn builtin_bbox(text: &str, size: u32) -> (i32, i32, i32, i32) {
let n = text.chars().count() as u32;
let scale = builtin_scale(size);
let w = if n == 0 {
0
} else {
(n * font5x7::ADVANCE - (font5x7::ADVANCE - font5x7::COLS)) * scale
};
(0, 0, w as i32, (font5x7::ROWS * scale) as i32)
}
/// Runs the Python size loop: 44, 42, ... down to 12, first size whose text
/// bbox width is <= `max_width` (the last tried size if none fits).
fn choose(
font: &LabelFont,
text: &str,
max_width: i32,
) -> (u32, Option<Shaped>, (i32, i32, i32, i32)) {
let mut size = 44;
let mut last = None;
while size > 10 {
let (shaped, bbox) = match &font.kind {
Kind::TrueType(tt) => {
let s = shape_truetype(tt, text, size);
let b = s.bbox;
(Some(s), b)
}
Kind::Builtin => (None, builtin_bbox(text, size)),
};
let fits = bbox.2 - bbox.0 <= max_width;
last = Some((size, shaped, bbox));
if fits {
break;
}
size -= 2;
}
last.expect("loop runs at least once")
}
/// Draws `text` centred in the strip `[strip_top, strip_top + strip_height)`
/// of `image` exactly following the Python placement rules, and returns the
/// layout.
pub(crate) fn draw_text(
image: &mut Bitmap,
font: &LabelFont,
text: &str,
strip_top: i32,
strip_height: i32,
) -> TextLayout {
let width = image.width() as i32;
let (size, shaped, bbox) = choose(font, text, width - 8);
let (bw, bh) = (bbox.2 - bbox.0, bbox.3 - bbox.1);
let origin = (
(width - bw).div_euclid(2) - bbox.0,
strip_top + (strip_height - bh).div_euclid(2) - bbox.1,
);
match (&font.kind, shaped) {
(Kind::TrueType(_), Some(shaped)) => {
let baseline = origin.1 + shaped.ascender;
for (px, ink) in &shaped.glyphs {
if ink.is_empty() {
continue;
}
let w = (ink.x1 - ink.x0) as usize;
for (r, row) in ink.ink.chunks(w).enumerate() {
let j = ink.j1 - 1 - r as i32;
let y = baseline - 1 - j;
for (c, &on) in row.iter().enumerate() {
if on {
let x = origin.0 + px + ink.x0 + c as i32;
image.set_clipped(i64::from(x), i64::from(y), true);
}
}
}
}
}
_ => {
let scale = builtin_scale(size) as i32;
for (i, ch) in text.chars().enumerate() {
let rows = font5x7::glyph(ch);
let gx = origin.0 + i as i32 * font5x7::ADVANCE as i32 * scale;
for (r, bits) in rows.iter().enumerate() {
for c in 0..font5x7::COLS as i32 {
if bits & (0x10 >> c) != 0 {
image.fill_rect(
i64::from(gx + c * scale),
i64::from(origin.1 + r as i32 * scale),
scale as u32,
scale as u32,
true,
);
}
}
}
}
}
}
TextLayout {
font: font.name().to_string(),
size,
bbox,
origin,
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Comparison against Python `render_label` (qrcode 8.2 + Pillow 12.3).
//!
//! Golden data from `tools/golden.py`:
//! * `qr_matrices.txt`: module matrices, versions and masks;
//! * `label_*.png`: complete Python labels;
//! * `text_info.txt`: Pillow's chosen font size, text bbox and draw origin.
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use lamp_label::{Bitmap, LabelFont, TEXT_HEIGHT, qr, render_label, render_label_with_font};
fn golden() -> 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 = info.color_type.samples();
Bitmap::from_fn(info.width, info.height, |x, y| {
buf[y as usize * info.line_size + x as usize * channels] < 128
})
}
/// id -> label png file name
fn label_files() -> HashMap<String, String> {
std::fs::read_to_string(golden().join("labels.txt"))
.unwrap()
.lines()
.filter(|l| !l.is_empty())
.map(|l| {
let (id, file) = l.split_once('\t').unwrap();
(id.to_string(), file.to_string())
})
.collect()
}
struct GoldenQr {
id: String,
version: u8,
mask: u8,
modules: Vec<Vec<bool>>,
}
fn golden_qr() -> Vec<GoldenQr> {
let text = std::fs::read_to_string(golden().join("qr_matrices.txt")).unwrap();
let mut lines = text.lines();
let mut out = Vec::new();
while let Some(header) = lines.next() {
if header.is_empty() {
continue;
}
let mut parts = header.rsplitn(4, ' ');
let size: usize = parts
.next()
.unwrap()
.strip_prefix("size=")
.unwrap()
.parse()
.unwrap();
let mask: u8 = parts
.next()
.unwrap()
.strip_prefix("mask=")
.unwrap()
.parse()
.unwrap();
let version: u8 = parts
.next()
.unwrap()
.strip_prefix("version=")
.unwrap()
.parse()
.unwrap();
let id = parts.next().unwrap().to_string();
let modules = (0..size)
.map(|_| lines.next().unwrap().bytes().map(|b| b == b'1').collect())
.collect();
out.push(GoldenQr {
id,
version,
mask,
modules,
});
}
out
}
#[test]
fn qr_matrices_match_python() {
let cases = golden_qr();
assert!(cases.len() >= 28, "golden data missing");
let mut failures = Vec::new();
for g in &cases {
let q = qr::encode(&g.id.to_uppercase()).unwrap();
let same_modules = (0..q.size).all(|r| (0..q.size).all(|c| q.get(c, r) == g.modules[r][c]));
if q.version != g.version || q.mask != g.mask || q.size != g.modules.len() || !same_modules
{
failures.push(format!(
"{}: rust v{} mask {} vs python v{} mask {} (modules equal: {same_modules})",
g.id, q.version, q.mask, g.version, g.mask
));
}
}
println!(
"QR: {}/{} matrices identical (version, mask and modules)",
cases.len() - failures.len(),
cases.len()
);
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn qr_region_of_label_matches_python() {
let files = label_files();
let qr_rows = 202 - TEXT_HEIGHT;
let mut checked = 0;
for g in golden_qr() {
let py = load_png(&golden().join(&files[&g.id]));
let rs = render_label(&g.id);
let diff = (0..qr_rows)
.flat_map(|y| (0..202).map(move |x| (x, y)))
.filter(|&(x, y)| py.get(x, y) != rs.get(x, y))
.count();
assert_eq!(diff, 0, "{}: {diff} pixels differ in the QR area", g.id);
checked += 1;
}
println!("QR area (rows 0..150) pixel-identical for {checked} labels");
}
struct TextGolden {
id: String,
font: String,
size: u32,
bbox: (i32, i32, i32, i32),
origin: (i32, i32),
}
fn text_golden() -> Vec<TextGolden> {
let text = std::fs::read_to_string(golden().join("text_info.txt")).unwrap();
text.lines()
.filter(|l| !l.is_empty())
.map(|l| {
let mut f = l.split('\t');
let id = f.next().unwrap().to_string();
let mut kv = HashMap::new();
for p in f {
let (k, v) = p.split_once('=').unwrap();
kv.insert(k, v);
}
let nums = |s: &str| {
s.split(',')
.map(|n| n.parse::<i32>().unwrap())
.collect::<Vec<_>>()
};
let b = nums(kv["bbox"]);
let o = nums(kv["origin"]);
TextGolden {
id,
font: kv["font"].to_string(),
size: kv["size"].parse().unwrap(),
bbox: (b[0], b[1], b[2], b[3]),
origin: (o[0], o[1]),
}
})
.collect()
}
#[test]
fn text_strip_close_to_python_with_arial_bold() {
let arial = Path::new("C:/Windows/Fonts/arialbd.ttf");
if !arial.exists() {
eprintln!("arialbd.ttf not available, skipping the text comparison");
return;
}
let font = LabelFont::from_file(arial).unwrap();
let files = label_files();
let top = 202 - TEXT_HEIGHT;
let mut report = Vec::new();
let mut layout_mismatch = Vec::new();
let mut too_different = Vec::new();
for g in text_golden() {
assert_eq!(g.font, "arialbd.ttf");
let (rs, layout) = render_label_with_font(&g.id, &font).unwrap();
let py = load_png(&golden().join(&files[&g.id]));
let (mut diff, mut ink_py, mut ink_rs) = (0, 0, 0);
for y in top..202 {
for x in 0..202 {
let (p, r) = (py.get(x, y), rs.get(x, y));
ink_py += usize::from(p);
ink_rs += usize::from(r);
diff += usize::from(p != r);
}
}
report.push(format!(
"{:<16} size py {:>2} rs {:>2} | bbox py {:?} rs {:?} | origin py {:?} rs {:?} | ink py {:>4} rs {:>4} | differing px {:>4} ({:.1} % of ink)",
g.id,
g.size,
layout.size,
g.bbox,
layout.bbox,
g.origin,
layout.origin,
ink_py,
ink_rs,
diff,
100.0 * diff as f64 / ink_py.max(1) as f64
));
if (layout.size, layout.bbox, layout.origin) != (g.size, g.bbox, g.origin) {
layout_mismatch.push(g.id.clone());
}
// glyph rasterisation may differ (no TrueType hinting), but the text
// must be essentially the same pixels
if diff * 4 >= ink_py {
too_different.push(format!(
"{}: {diff} differing pixels for {ink_py} ink pixels",
g.id
));
}
}
println!("text strip vs Python (arialbd.ttf):\n{}", report.join("\n"));
assert!(too_different.is_empty(), "{}", too_different.join("\n"));
// chosen size, text bbox and draw origin must be exactly Pillow's
assert!(
layout_mismatch.is_empty(),
"size/bbox/origin differ for {layout_mismatch:?}"
);
}
#[test]
fn builtin_font_fallback_renders() {
let font = LabelFont::builtin();
let (img, layout) = render_label_with_font("L0006", &font).unwrap();
assert_eq!(layout.font, "builtin-5x7");
assert_eq!(layout.size, 44);
// 5 glyphs of 5x7 cells at scale 4: 116 x 28, centred in the strip
assert_eq!(layout.bbox, (0, 0, 116, 28));
assert_eq!(layout.origin, (43, 162));
let ink: usize = (150..202)
.flat_map(|y| (0..202).map(move |x| (x, y)))
.filter(|&(x, y)| img.get(x, y))
.count();
assert!(ink > 500, "text missing ({ink} px)");
// long IDs shrink
let (_, layout) = render_label_with_font("L0123456789012345", &font).unwrap();
assert!(layout.size < 44 && layout.bbox.2 <= 194, "{layout:?}");
// same QR as with the TrueType font
let (img2, _) = render_label_with_font("L0006", &LabelFont::system()).unwrap();
assert!((0..150).all(|y| (0..202).all(|x| img.get(x, y) == img2.get(x, y))));
}
#[test]
fn png_roundtrip_and_preview() {
let label = render_label("L0006");
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
let path = dir.join("lamp_label_L0006.png");
lamp_label::save_png(&label, &path).unwrap();
assert_eq!(load_png(&path), label);
println!("preview written to {}", path.display());
}
#[test]
fn instructions_for_two_labels() {
let data = lamp_label::label_instructions("L0006", 2).unwrap();
// Python output for the Python-rendered L0006 label (brother-ql golden data):
// same length and identical command bytes; only raster lines carrying
// text pixels may differ.
let py = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../brother-ql/tests/golden/label_L0006_x2.bin"),
)
.unwrap();
assert_eq!(data.len(), py.len());
// preamble 4 + 400 + 2 + 4, page header 3 + 13 + 4 + 4 + 4 + 5, 202 lines of 93, 0x1A
let (pre, head, lines) = (410, 33, 202 * 93);
let page = head + lines + 1;
assert_eq!(data.len(), pre + 2 * page);
assert_eq!(data[..pre + head], py[..pre + head]);
for p in 0..2 {
let start = pre + p * page;
assert_eq!(data[start..start + head], py[start..start + head]);
assert_eq!(data[start + page - 1], 0x1A);
for l in 0..202 {
let row = start + head + l * 93;
assert_eq!(data[row..row + 3], [0x67, 0x00, 90]);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

+35
View File
@@ -0,0 +1,35 @@
L0001 label_L0001.png
L0002 label_L0002.png
L0003 label_L0003.png
L0004 label_L0004.png
L0005 label_L0005.png
L0006 label_L0006.png
L0007 label_L0007.png
L0008 label_L0008.png
L0009 label_L0009.png
L0010 label_L0010.png
L0011 label_L0011.png
L0012 label_L0012.png
L0013 label_L0013.png
L0014 label_L0014.png
L0015 label_L0015.png
L0016 label_L0016.png
L0017 label_L0017.png
L0018 label_L0018.png
L0019 label_L0019.png
L0020 label_L0020.png
L0999 label_L0999.png
L1000 label_L1000.png
L12345 label_L12345.png
L123456 label_L123456.png
12345678 label_12345678.png
LAMP-7 X label_LAMP-7_X.png
lamp_7 label_lamp_7.png
https://example.com/lamp/L0006?x=1 label_https___example_com_lamp_L0006_x_1.png
L0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 label_L012345678901234567890123456789012345678.png
LABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJ label_LABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHI.png
L0123 label_L0123.png
L9 label_L9.png
L0123456789012 label_L0123456789012.png
LAVA-7 label_LAVA-7.png
WWWWWWWWWW label_WWWWWWWWWW.png
@@ -0,0 +1,704 @@
L0001 version=1 mask=7 size=21
111111100000101111111
100000100010101000001
101110100010001011101
101110100101101011101
101110100101101011101
100000101000001000001
111111101010101111111
000000000100000000000
100101101011110100000
011101001011010101000
111111111101001110111
110001011000111110000
011101101011011100100
000000001010011011011
111111100001101011101
100000101010001001011
101110100000111000111
101110101110011100111
101110100011011100101
100000100001100101001
111111101110010101100
L0002 version=1 mask=7 size=21
111111100100001111111
100000100010001000001
101110100101101011101
101110100110101011101
101110100111101011101
100000101010001000001
111111101010101111111
000000000000000000000
100101101001110100000
001100010101010101000
110111100111001110111
000010011010111110000
111101110011011100100
000000001000011011011
111111100001101011101
100000101100001001011
101110100110111001111
101110101100011100011
101110100101011100101
100000100111100101001
111111101100010101100
L0003 version=1 mask=0 size=21
111111100110101111111
100000101111001000001
101110100111001011101
101110100111001011101
101110101111101011101
100000100101001000001
111111101010101111111
000000000000000000000
101010100010100010010
001111010011010101000
010101101011011100101
110110011011110111001
000101100011011100100
000000001110001001001
111111100010100010100
100000100000001001011
101110101010101011101
101110100101010101110
101110101101011100101
100000100111110111011
111111101101011100101
L0004 version=1 mask=6 size=21
111111101000001111111
100000101101001000001
101110101100101011101
101110100111101011101
101110101010101011101
100000100111101000001
111111101010101111111
000000000011100000000
100111111100110010111
000000001010101010111
101110111000011011101
100110011111000001111
011100111110001001110
000000001111100100100
111111101110111110111
100000101111110110100
101110101111101101001
101110101101100011100
101110100000001001111
100000100100011010110
111111101111000000110
L0005 version=1 mask=7 size=21
111111100111101111111
100000100101101000001
101110100010001011101
101110100001101011101
101110100101101011101
100000101110001000001
111111101010101111111
000000000110000000000
100101101011110100000
111100010011010101000
001011110011001110111
100100001000111110000
110001111011011100100
000000001100011011011
111111100001101011101
100000101100001001011
101110100100111000011
101110101010011100111
101110100101011100101
100000100001100101001
111111101010010101100
L0006 version=1 mask=5 size=21
111111100110001111111
100000101010101000001
101110101010101011101
101110101101001011101
101110100011101011101
100000100000101000001
111111101010101111111
000000001101100000000
100000101110111001110
010010010010101010111
011111110101010010100
101011010110000010111
000100110110001001110
000000001001111100111
111111100110101100101
100000100101110110100
101110100110100101000
101110100100100000100
101110100110001001111
100000100000000010101
111111101111010010100
L0007 version=1 mask=1 size=21
111111101100101111111
100000100101001000001
101110101010001011101
101110100110001011101
101110100010101011101
100000101110001000001
111111101010101111111
000000000111000000000
101000110111100100101
111011011110000000010
110100110000001001111
110110011110100010011
111100100110001001110
000000001101011100011
111111101111110111110
100000100011011100001
101110100011111110011
101110100100000000100
101110101110001001111
100000100010100010001
111111101100001001111
L0008 version=1 mask=6 size=21
111111101001101111111
100000101100101000001
101110101010101011101
101110100101101011101
101110101110101011101
100000100111101000001
111111101010101111111
000000000001100000000
100111111100110010111
011001011110101010111
100110100100011011101
110000010111000001111
101110100110001001110
000000001011100100100
111111101110111110111
100000101101110111100
101110101101101101101
101110101111100011100
101110100100001001111
100000100100011010110
111111101001000000110
L0009 version=1 mask=6 size=21
111111101011001111111
100000101011101000001
101110101001001011101
101110100100001011101
101110101100101011101
100000100001101000001
111111101010101111111
000000000011100000000
100111111110110010111
011010011000101010111
010110111010011011101
001101011111000001111
010110110110001001110
000000001111100100100
111111101100111110111
100000101001110111100
101110101011101101101
101110101111100011000
101110100100001001111
100000100110011010110
111111101001000000110
L0010 version=1 mask=7 size=21
111111100000001111111
100000100111101000001
101110100010101011101
101110100011001011101
101110100101101011101
100000101100001000001
111111101010101111111
000000000010000000000
100101101111110100000
101000011101010101000
000110101001001110111
001001000100111110000
010101100101011100100
000000001010011011011
111111100011101011001
100000101000001001011
101110100110111000111
101110101010011100011
101110100011011100101
100000100011100101001
111111101000010101100
L0011 version=1 mask=2 size=21
111111100100101111111
100000100011001000001
101110101110001011101
101110101011001011101
101110101011101011101
100000101010101000001
111111101010101111111
000000001011100000000
101111100010101111100
011010001010100100110
101010101011010010100
001100010100000110111
100011111011010010101
000000001101111000111
111111100110101100001
100000101101111000101
101110101100100100100
101110101010100100000
101110101101010010100
100000100010000110101
111111101111010010100
L0012 version=1 mask=0 size=21
111111100110001111111
100000101010001000001
101110100111101011101
101110100001101011101
101110101111101011101
100000100001001000001
111111101010101111111
000000000110000000000
101010100110100010010
111010000101010101000
101100111111011100101
001110000111110111001
001101101101011100100
000000001110001001001
111111100000100010000
100000100010001001011
101110101100101011101
101110100001010101010
101110101101011100101
100000100101110111011
111111101011011100101
L0013 version=1 mask=3 size=21
111111101010101111111
100000101001001000001
101110100100101011101
101110101001101011101
101110100110001011101
100000100000001000001
111111101010101111111
000000001000000000000
101101110100001001011
001000010010100100110
111111100010111111001
110100001000110000001
111011110011010010101
000000001110010101010
111111101010011010111
100000101111111000101
101110100001001000001
101110101110010010110
101110101011010010100
100000100011101011000
111111101011100100010
L0014 version=1 mask=7 size=21
111111100111001111111
100000100000101000001
101110100010101011101
101110100111001011101
101110100101101011101
100000101010001000001
111111101010101111111
000000000000000000000
100101101111110100000
001001000101010101000
110010100111001110111
011100010100111110000
111001110101011100100
000000001100011011011
111111100011101011001
100000101110001001011
101110100010111000011
101110101110011100011
101110100101011100101
100000100011100101001
111111101100010101100
L0015 version=1 mask=4 size=21
111111101111101111111
100000100000001000001
101110100101101011101
101110101100101011101
101110101111101011101
100000101000101000001
111111101010101111111
000000001010000000000
100010111110111111001
100111001110111000101
111101100110110001000
111010000111100101011
010011110111001110110
000000001111100100100
111111101101001111101
100000100000011011001
101110101100111000011
101110100010111000011
101110100000110001000
100000100001100101001
111111101111001110111
L0016 version=1 mask=6 size=21
111111101100001111111
100000101000101000001
101110101011101011101
101110100010001011101
101110101000101011101
100000100001101000001
111111101010101111111
000000000001100000000
100111111010110010111
100100000010101010111
011111110110011011101
101101000001000001111
110100101000001001110
000000001101100100100
111111101100111110011
100000101011110110100
101110101111101100001
101110101011100011100
101110100110001001111
100000100000011010110
111111101011000000110
L0017 version=1 mask=3 size=21
111111101101101111111
100000101110001000001
101110100100101011101
101110101101101011101
101110100110001011101
100000100110001000001
111111101010101111111
000000001010000000000
101101110100001001011
101001001010100100110
001011101100111111001
100001011000110000001
010111100011010010101
000000001000010101010
111111101010011010111
100000101001111000101
101110100101001000101
101110101010010010110
101110101101010010100
100000100011101011000
111111101111100100010
L0018 version=1 mask=6 size=21
111111101011101111111
100000101110101000001
101110101001101011101
101110100010101011101
101110101100101011101
100000100101101000001
111111101010101111111
000000000101100000000
100111111010110010111
101111001110101010111
101111101110011011101
110101000011000001111
011110111000001001110
000000001111100100100
111111101110111110011
100000101011110111100
101110101101101101101
101110101011100011100
101110100100001001111
100000100100011010110
111111101111000000110
L0019 version=1 mask=6 size=21
111111101001001111111
100000101001101000001
101110101010001011101
101110100011001011101
101110101110101011101
100000100011101000001
111111101010101111111
000000000111100000000
100111111000110010111
101100001000101010111
011111110000011011101
001000001011000001111
100110101000001001110
000000001011100100100
111111101100111110011
100000101111110111100
101110101011101101101
101110101011100011000
101110100100001001111
100000100110011010110
111111101111000000110
L0020 version=1 mask=0 size=21
111111100110001111111
100000101111101000001
101110100010001011101
101110100010101011101
101110101001101011101
100000100011001000001
111111101010101111111
000000000100000000000
101010100110100010010
001101010001010101000
110011101001011100101
010101010111110111001
011101110001011100100
000000001110001000001
111111100110100010100
100000100100001001011
101110101100101010101
101110100101010101010
101110101011011100101
100000100101110111011
111111101101011100101
L0999 version=1 mask=0 size=21
111111100001001111111
100000101011101000001
101110100110001011101
101110100100101011101
101110101101101011101
100000100111001000001
111111101010101111111
000000000100000000000
101010100100100010010
010111011011010101000
000001101011011101001
011111010101110111001
011000110001011101000
000000001100001000101
111111100100100010100
100000100010001000011
101110101100101010101
101110100011010101110
101110101011011100101
100000100101110111011
111111101111011100101
L1000 version=1 mask=4 size=21
111111101110101111111
100000100100001000001
101110100111101011101
101110101011001011101
101110101101101011101
100000101010101000001
111111101010101111111
000000001000000000000
100010111000111111001
111101001000111000110
110011110100110001000
010011010001100101011
010100101101001110110
000000001011100100100
111111101011001111001
100000100110011011001
101110101100111000111
101110100100111000111
101110100100110001000
100000100101100101001
111111101111001110111
L12345 version=1 mask=2 size=21
111111100111001111111
100000100000101000001
101110101010001011101
101110101100101011101
101110101100101011101
100000101001001000001
111111101010101111111
000000001010000000000
101111100001001111100
111011010111111000101
110101100110101100100
011011001011111000011
100110100110100101001
000000001110100100011
111111100011010010000
100000101110000110111
101110101101010010000
101110101011111101000
101110101110101100000
100000100001111101101
111111101010100100100
L123456 version=1 mask=1 size=21
111111101100101111111
100000100111101000001
101110101011001011101
101110100111101011101
101110100101101011101
100000101111101000001
111111101010101111111
000000000100100000000
101000110100000100101
111100001001011100001
110001111101110111111
111011010111011100111
111111101001111110010
000000001100000000111
111111101000001001010
100000100010100010011
101110100110001001011
101110100101011001100
101110101111110111011
100000100111011011001
111111101101111011111
12345678 version=1 mask=6 size=21
111111101011001111111
100000101000101000001
101110101101101011101
101110100010001011101
101110101100101011101
100000100111101000001
111111101010101111111
000000000101100000000
100111111110110010111
101010010110101011110
101110100100011010000
111101011011000001111
111001111010001001001
000000001011100100111
111111101100111110010
100000101011110111100
101110101001101100001
101110101111100011100
101110100000001001111
100000100010011010101
111111101001000000110
LAMP-7 X version=1 mask=2 size=21
111111100110101111111
100000100011001000001
101110101100001011101
101110101010101011101
101110101101101011101
100000101100101000001
111111101010101111111
000000001111100000000
101111100100101111100
110001000100100101111
011110110101010011001
010110011010000111011
100111101101010010001
000000001011111101011
111111100010101101000
100000101101111101000
101110101000100110110
101110101010100111000
101110101111010111000
100000100100000010101
111111101101010101000
lamp_7 version=1 mask=7 size=21
111111100101101111111
100000100110101000001
101110100110001011101
101110100000101011101
101110100111101011101
100000101110001000001
111111101010101111111
000000000110000000000
100101101001110100000
011100011111010101010
111000111111001111101
101110001110111111011
001010111101011100110
000000001000011110100
111111100001101010110
100000101010001010011
101110100010111101101
101110101010011010011
101110100101011010101
100000100111100000000
111111101000010100110
https://example.com/lamp/L0006?x=1 version=2 mask=0 size=25
1111111000000011101111111
1000001011000110101000001
1011101001000011001011101
1011101000110100101011101
1011101011001011101011101
1000001000011100101000001
1111111010101010101111111
0000000001001100100000000
1010101000010011000010010
1001010011010101111010011
1101111111011011100111111
1100010011101100100100011
1000011100011011111110100
0000110100011010100100011
1000011011001011101100000
0101100011011110010111000
1000101010111010111111001
0000000010111100100010101
1111111001100010101011101
1000001001101111100010101
1011101010111010111111110
1011101000110001010011010
1011101010101100000010001
1000001000101011010011111
1111111011100001110111101
L0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 version=4 mask=1 size=33
111111101100110101101111001111111
100000100111000000110001101000001
101110101101110101100101001011101
101110100101000100011011001011101
101110100100010100110010001011101
100000101110011100000001001000001
111111101010101010101010101111111
000000000001100111001101100000000
101000110011000011001101100100101
011111001110010001101101110000001
001010100000100111010000010101000
011010010011010010000100000110000
000001111010010101011011100111101
100010000101100001100001000110110
001100101110100100001100101011011
011101011111110000101011100110100
011110101100110100001101100101100
010001011100100010111101001100110
101111101110110110001110001101111
101010011101000010011101110010001
011001101110100010010101101101100
010001011110101011001011100111100
110100101100010010101111010010101
001100000000100110011100101001110
111011101001100011101101111110011
000000001101111001110101100011101
111111101010011010101000101011101
100000100000001010011011100011001
101110100111110010010000111110110
101110100110011001000011001011000
101110101101111100011100011010011
100000100110010001010010000010100
111111101000000001001000011011001
LABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJ version=8 mask=4 size=49
1111111011011110001001011000110110001100101111111
1000001001010010100101010111111001110011101000001
1011101000110011010011010010110000001001101011101
1011101011111100100001101110010010000001001011101
1011101010010110001000111111100011100000001011101
1000001011110101101101100010011001001110001000001
1111111010101010101010101010101010101010101111111
0000000011010011001111100011000100010000000000000
1000101110100001010010111110011001010001011111001
1101100001001010010100100011000010100001101010010
0010101010001010110011001010000101111101011111111
1010110111011011010100011010000110010010011101011
1110111011110001000010010001001100111010110001000
1010110111001100000110000100010011101011001110110
0100101011001111011100111011001110111100010110011
0111000011100011110001100010111010011010001111101
1111001011011000111001010000001101011110011000000
1110000010000110101000100111011100100011100110011
0110101011100100010110101001101110000001011100011
0111110010100101100001000001101011010010111100000
0101111001010011110101111011110000110000101101111
0100000001111110110011111110100011000101111010010
0011111110001110101010111110101000100010111110011
1101100010101011100100100011011100111010100010100
0111101010110111111001101011110101110001101010110
0111100010000001000011100011101100100110100011110
1010111111100101110011111110110100011000111110011
1101110111000000010000011100101111100111101101011
1011101110100100011110111111111110110010000101101
0100110011111101000001101010000110100011110001110
0110111101110111001011100000011110001101011110011
0011010010110010110110110111111011100110111100110
1110001001100110000011101101100100011000110100111
0001100101110001001111011000111110110000010101011
1010001100001100110011001000011100100101100011001
1011010001110000100101111111000010101010011111000
1111101100101010001010011101001011110000111111011
1100110111100111001011001011000001001110111100010
0100011101110000001110011110111000110111100100110
0111000010010001011100000111001110110010101110010
1110001111101110011110111110010000111010111110110
0000000010010011001001100010011000010000100011011
1111111010101010111011101010011000111111101011101
1000001001110010010101100011000011000010100011011
1011101010100000100011111110111010101011111110110
1011101001001001110111000000111100100101100011100
1011101000011110101100100010001100000111101111100
1000001000011101110000001111110110000010000101111
1111111011101010011000110110001101111100100010101
@@ -0,0 +1,10 @@
L0001 font=arialbd.ttf size=44 bbox=0,8,125,40 origin=38,152
L0006 font=arialbd.ttf size=44 bbox=0,8,125,40 origin=38,152
L0123 font=arialbd.ttf size=44 bbox=0,8,125,40 origin=38,152
L12345 font=arialbd.ttf size=44 bbox=0,8,149,40 origin=26,152
L9 font=arialbd.ttf size=44 bbox=0,8,51,40 origin=75,152
L123456 font=arialbd.ttf size=44 bbox=0,8,174,40 origin=14,152
L0123456789012 font=arialbd.ttf size=24 bbox=0,5,188,22 origin=7,162
LAVA-7 font=arialbd.ttf size=44 bbox=0,8,152,40 origin=25,152
WWWWWWWWWW font=arialbd.ttf size=20 bbox=0,4,189,19 origin=6,164
lamp_7 font=arialbd.ttf size=44 bbox=0,8,152,49 origin=25,147
+161
View File
@@ -0,0 +1,161 @@
"""Generate golden data for the Rust lamp-label crate.
Writes into ``crates/lamp-label/tests/golden/``:
* ``qr_matrices.txt``: for each ID, the QR code Python ``qrcode`` 8.2 builds
for ``render_label`` (ERROR_CORRECT_M, fit=True, automatic mask): a header
line ``<id> version=<v> mask=<m> size=<n>`` followed by ``n`` rows of
``0``/``1`` (1 = dark), quiet zone not included.
* ``label_<id>.png``: the complete 202x202 label from Python ``render_label``.
* ``text_info.txt``: per ID the font, chosen size, PIL text bbox and the draw
origin used by ``render_label``.
Run with: C:/dev/HPCS/.venv/Scripts/python.exe -W ignore crates/lamp-label/tools/golden.py
"""
import argparse
import importlib.util
import os
import re
from pathlib import Path
import qrcode
from PIL import Image, ImageDraw, ImageFont
OUT = Path(__file__).resolve().parent.parent / "tests" / "golden"
DOTS = (202, 202)
TEXT_HEIGHT = 52
QR_IDS = [f"L{i:04d}" for i in range(1, 21)] + [
"L0999",
"L1000",
"L12345",
"L123456",
# mode / version coverage
"12345678", # numeric mode
"LAMP-7 X", # alphanumeric with space
"lamp_7", # upper-cased, '_' -> byte mode
"https://example.com/lamp/L0006?x=1", # alnum chunk + byte chunk
"L" + "0123456789" * 13, # byte 'L' + numeric chunk, version 4
"L" + "ABCDEFGHIJ" * 20, # alphanumeric, version 8 (version info)
]
TEXT_IDS = ["L0001", "L0006", "L0123", "L12345", "L9", "L123456", "L0123456789012", "LAVA-7", "WWWWWWWWWW", "lamp_7"]
# ---- verbatim copy of label_printer.py (HPCS) -------------------------------
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 text_layout(lamp_id):
"""Same loop as render_label, returning what it decided."""
draw = ImageDraw.Draw(Image.new("1", DOTS, 1))
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
origin = (
(DOTS[0] - (box[2] - box[0])) // 2 - box[0],
DOTS[1] - TEXT_HEIGHT + (TEXT_HEIGHT - (box[3] - box[1])) // 2 - box[1],
)
return font, size, box, origin
def file_safe(lamp_id):
return re.sub(r"[^A-Za-z0-9_-]", "_", lamp_id)[:40]
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", ".txt"):
f.unlink()
lines = []
for lamp_id in QR_IDS:
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)
mask = qr.best_mask_pattern() # deterministic; same choice make() used
qr.make(fit=True)
n = qr.modules_count
lines.append(f"{lamp_id} version={qr.version} mask={mask} size={n}")
for row in qr.modules:
lines.append("".join("1" if m else "0" for m in row))
(OUT / "qr_matrices.txt").write_bytes(("\n".join(lines) + "\n").encode("utf-8"))
names = []
for lamp_id in dict.fromkeys(QR_IDS + TEXT_IDS):
im = render_label(lamp_id)
if reference is not None:
assert reference.render_label(lamp_id).tobytes() == im.tobytes(), lamp_id
name = f"label_{file_safe(lamp_id)}.png"
im.save(OUT / name)
names.append(f"{lamp_id}\t{name}")
(OUT / "labels.txt").write_bytes(("\n".join(names) + "\n").encode("utf-8"))
info = []
for lamp_id in TEXT_IDS:
font, size, box, origin = text_layout(lamp_id)
path = Path(getattr(font, "path", "builtin")).name
info.append(
f"{lamp_id}\tfont={path}\tsize={size}\tbbox={box[0]},{box[1]},{box[2]},{box[3]}\torigin={origin[0]},{origin[1]}"
)
(OUT / "text_info.txt").write_bytes(("\n".join(info) + "\n").encode("utf-8"))
print(f"{len(QR_IDS)} QR matrices, {len(names)} labels, {len(info)} text layouts -> {OUT}")
print("reference render_label checked" if reference else "reference render_label NOT checked")
for line in info:
print(line)
if __name__ == "__main__":
main()