From 9661697613e150739e677c9a2b5711b3e374c328 Mon Sep 17 00:00:00 2001 From: cxm Date: Fri, 5 Jun 2026 16:14:39 +0800 Subject: [PATCH 01/12] wip: add tspl parser --- README.md | 36 +- docs/USAGE.md | 38 +- src/drawers/renderer.rs | 100 +++- src/elements/barcode_qr.rs | 2 + src/elements/field_alignment.rs | 1 + src/elements/graphic_ellipse.rs | 13 + src/elements/graphic_field.rs | 8 + src/elements/label_element.rs | 3 + src/elements/mod.rs | 1 + src/lib.rs | 1 + src/main.rs | 221 ++++++-- src/parsers/mod.rs | 2 + src/parsers/tspl_parser.rs | 858 ++++++++++++++++++++++++++++++++ tests/unit_tspl_parser.rs | 260 ++++++++++ 14 files changed, 1461 insertions(+), 83 deletions(-) create mode 100644 src/elements/graphic_ellipse.rs create mode 100644 src/parsers/tspl_parser.rs create mode 100644 tests/unit_tspl_parser.rs diff --git a/README.md b/README.md index 6ffeda9..9d9e7aa 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Labelize — ZPL / EPL Label Renderer +# Labelize — ZPL / EPL / TSPL Label Renderer [![Crates.io](https://img.shields.io/crates/v/labelize)](https://crates.io/crates/labelize) [![License](https://img.shields.io/github/license/GOODBOY008/labelize)](LICENSE) [![Build](https://img.shields.io/github/actions/workflow/status/GOODBOY008/labelize/ci.yml?branch=main)](https://github.com/GOODBOY008/labelize/actions) -> **Turn ZPL/EPL into pixels — label rendering, simplified.** +> **Turn ZPL/EPL/TSPL into pixels — label rendering, simplified.** -Labelize is a fast, open-source Rust engine that parses **ZPL** (Zebra Programming Language) and **EPL** (Eltron Programming Language) label data and renders it to **PNG** or **PDF**. Use it as a **CLI tool**, an **HTTP microservice**, or embed it as a **Rust library** — no printer hardware required. +Labelize is a fast, open-source Rust engine that parses **ZPL** (Zebra Programming Language), **EPL** (Eltron Programming Language), and **TSPL** label data and renders it to **PNG** or **PDF**. Use it as a **CLI tool**, an **HTTP microservice**, or embed it as a **Rust library** — no printer hardware required. If you need a self-hosted, offline alternative to [Labelary](http://labelary.com/) for previewing and converting thermal label formats, Labelize has you covered. @@ -39,11 +39,12 @@ Benchmarked against the Labelary API on the same set of labels: - **ZPL Parser** — 30+ ZPL commands: text, barcodes, graphics, stored formats, graphic fields, field blocks, and more - **EPL Parser** — EPL command support for text, barcodes, line draw, and reference points +- **TSPL Parser** — render-focused support for `SIZE`, text, graphics, bitmaps, QR/PDF417, and common 1D barcodes - **10 Barcode Symbologies** — Code 128, Code 39, EAN-13, Interleaved 2-of-5, PDF417, Aztec, DataMatrix, QR Code, MaxiCode - **PNG & PDF Output** — Monochrome 1-bit PNG or single-page embedded PDF output -- **CLI Tool** — Convert ZPL/EPL files from the command line with format auto-detection, multi-label support, and customizable label dimensions +- **CLI Tool** — Convert ZPL/EPL/TSPL files from the command line with format auto-detection, multi-label support, and customizable label dimensions - **HTTP Microservice** — RESTful API for label conversion with format detection via `Content-Type` header; deploy anywhere with Docker or bare metal -- **Web Playground** — Built-in browser UI at `GET /` — paste or open a `.zpl`/`.epl` file, choose a label size (4×6, 4×4, etc.), render PNG inline, and download PNG or PDF with one click +- **Web Playground** — Built-in browser UI at `GET /` — paste or open a `.zpl`/`.epl`/`.tspl` file, choose a label size (4×6, 4×4, etc.), render PNG inline, and download PNG or PDF with one click - **Embedded Fonts** — Zero runtime font dependencies; bundles Helvetica Bold Condensed, DejaVu Sans Mono, and ZPL GS fonts - **Rust Library** — Integrate label rendering directly into your Rust application via the public API @@ -64,6 +65,7 @@ cargo install --path . ```bash labelize convert label.zpl # → label.png (format auto-detected) labelize convert label.epl # EPL works too +labelize convert label.tspl # TSPL SIZE controls dimensions unless overridden labelize convert label.zpl -t pdf # output as PDF labelize convert label.zpl --width 100 --height 62 --dpmm 12 # custom dimensions ``` @@ -74,7 +76,7 @@ labelize convert label.zpl --width 100 --height 62 --dpmm 12 # custom dimension labelize serve --port 8080 ``` -Open **http://localhost:8080/** in your browser to use the built-in **interactive playground** — paste ZPL/EPL, pick a label size, and render PNG instantly. Download PNG or PDF directly from the page. +Open **http://localhost:8080/** in your browser to use the built-in **interactive playground** — paste ZPL/EPL/TSPL, pick a label size, and render PNG instantly. Download PNG or PDF directly from the page. ```bash # Convert via REST API @@ -90,16 +92,16 @@ curl -X POST http://localhost:8080/convert \ Usage: labelize Commands: - convert Convert a ZPL/EPL file to PNG or PDF + convert Convert a ZPL/EPL/TSPL file to PNG or PDF serve Start HTTP server for label conversion Convert Options: - Input file path (.zpl or .epl) + Input file path (.zpl, .epl, or .tspl) -o, --output Output file path (default: input stem + .png/.pdf) - -f, --format Input format override: zpl | epl + -f, --format Input format override: zpl | epl | tspl -t, --type Output type: png | pdf [default: png] - --width Label width in mm [default: 102] - --height Label height in mm [default: 152] + --width Label width override in mm + --height Label height override in mm --dpmm Dots per mm [default: 8] Serve Options: @@ -124,7 +126,7 @@ Serve Options: | `dpmm` | 8 | Dots per mm | | `output` | png | Output format: png/pdf | -Set `Content-Type: application/zpl` or `Content-Type: application/epl` to select the parser. +Set `Content-Type: application/zpl`, `application/epl`, or `application/tspl` to select the parser. For TSPL, `SIZE` supplies render dimensions unless `width` or `height` is provided explicitly. ## Library Usage @@ -143,7 +145,7 @@ renderer.draw_label_as_png(&labels[0], &mut buf, DrawerOptions::default()).unwra std::fs::write("output.png", buf.into_inner()).unwrap(); ``` -## Supported ZPL & EPL Commands +## Supported ZPL, EPL & TSPL Commands ### ZPL Commands @@ -159,10 +161,14 @@ std::fs::write("output.png", buf.into_inner()).unwrap(); `N` (new label) · `A` (text) · `B` (barcode) · `LO` (line draw) · `R` (reference point) · `P` (print) +### TSPL Commands + +`SIZE` · `DIRECTION` · `REFERENCE` · `SHIFT` · `CLS` · `PRINT` · `TEXT` · `BAR` · `BOX` · `CIRCLE` · `ELLIPSE` · `ERASE` · `REVERSE` · `BITMAP` · `BARCODE` · `QRCODE` · `PDF417` + ## Architecture ``` - ZPL/EPL input + ZPL/EPL/TSPL input │ ▼ ┌─────────┐ ┌──────────┐ ┌─────────┐ @@ -226,7 +232,7 @@ cargo build --release ## Related Projects & Keywords -Looking for a **ZPL renderer**, **ZPL to PNG converter**, **ZPL to PDF**, **EPL parser**, **Zebra label preview**, **thermal label rendering**, or **Labelary alternative**? Labelize covers all of these. +Looking for a **ZPL renderer**, **ZPL to PNG converter**, **ZPL to PDF**, **EPL parser**, **TSPL parser**, **Zebra label preview**, **thermal label rendering**, or **Labelary alternative**? Labelize covers all of these. ## Contributing diff --git a/docs/USAGE.md b/docs/USAGE.md index 49ebfd9..cf464d9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -38,6 +38,13 @@ labelize convert label.epl # → label.png (auto-detected from extension) ``` +### Convert a TSPL file to PNG + +```bash +labelize convert label.tspl +# → label.png (TSPL SIZE controls dimensions unless overridden) +``` + ### Specify output path ```bash @@ -55,6 +62,7 @@ labelize convert label.zpl -t pdf ```bash labelize convert data.txt --format zpl +labelize convert data.txt --format tspl ``` ### Custom label dimensions @@ -65,10 +73,10 @@ labelize convert label.zpl --width 100 --height 150 --dpmm 12 | Option | Default | Description | |-------------|---------|------------------------------------------| -| `--width` | 102 | Label width in mm | -| `--height` | 152 | Label height in mm | +| `--width` | 102 or TSPL `SIZE` | Label width override in mm | +| `--height` | 152 or TSPL `SIZE` | Label height override in mm | | `--dpmm` | 8 | Dots per mm (6, 8, 12, or 24) | -| `-f, --format` | auto | Input format: `zpl` or `epl` | +| `-f, --format` | auto | Input format: `zpl`, `epl`, or `tspl` | | `-t, --type` | png | Output type: `png` or `pdf` | | `-o, --output` | auto | Output file path | @@ -123,6 +131,15 @@ curl -X POST http://localhost:8080/convert \ -o label.png ``` +### Convert TSPL to PNG via HTTP + +```bash +curl -X POST http://localhost:8080/convert \ + -H "Content-Type: application/tspl" \ + --data-binary @label.tspl \ + -o label.png +``` + ### Convert to PDF via HTTP Add `?output=pdf` to the URL: @@ -145,8 +162,8 @@ curl -X POST "http://localhost:8080/convert?width=100&height=62&dpmm=12" \ | Parameter | Default | Description | |-----------|---------|-------------------------| -| `width` | 102 | Label width in mm | -| `height` | 152 | Label height in mm | +| `width` | 102 or TSPL `SIZE` | Label width override in mm | +| `height` | 152 or TSPL `SIZE` | Label height override in mm | | `dpmm` | 8 | Dots per mm | | `output` | png | Output format: png/pdf | @@ -262,3 +279,14 @@ fn main() { | `LO` | Line draw (graphic box) | | `R` | Reference point | | `P` | Print label | + +## Supported TSPL Commands + +| Category | Commands | +|----------|----------| +| **Layout** | `SIZE` `DIRECTION` `REFERENCE` `SHIFT` `CLS` `PRINT` | +| **Text** | `TEXT` | +| **Graphics** | `BAR` `BOX` `CIRCLE` `ELLIPSE` `ERASE` `REVERSE` `BITMAP` | +| **Barcodes** | `BARCODE` (`128`, `EAN128`, `39`, `39C`, `EAN13`) `QRCODE` `PDF417` | + +Printer-control commands such as `GAP`, `SPEED`, `DENSITY`, and `SET` are parsed as no-ops because they do not affect rendered output. diff --git a/src/drawers/renderer.rs b/src/drawers/renderer.rs index 74e94f2..6d10b25 100644 --- a/src/drawers/renderer.rs +++ b/src/drawers/renderer.rs @@ -145,6 +145,10 @@ impl Renderer { self.draw_graphic_circle(canvas, gc); Ok(()) } + LabelElement::GraphicEllipse(ge) => { + self.draw_graphic_ellipse(canvas, ge); + Ok(()) + } LabelElement::DiagonalLine(dl) => { self.draw_diagonal_line(canvas, dl); Ok(()) @@ -370,6 +374,47 @@ impl Renderer { } } + fn draw_graphic_ellipse( + &self, + canvas: &mut RgbaImage, + ge: &crate::elements::graphic_ellipse::GraphicEllipse, + ) { + let color = line_color_to_rgba(ge.line_color); + let x = ge.position.x; + let y = ge.position.y; + let width = ge.width.max(1); + let height = ge.height.max(1); + let thickness = ge.border_thickness.max(1) as f32; + let rx = width as f32 / 2.0; + let ry = height as f32 / 2.0; + let cx = x as f32 + rx; + let cy = y as f32 + ry; + let inner_rx = (rx - thickness).max(0.0); + let inner_ry = (ry - thickness).max(0.0); + + for py in y.max(0)..(y + height).min(canvas.height() as i32) { + for px in x.max(0)..(x + width).min(canvas.width() as i32) { + let dx = (px as f32 + 0.5 - cx) / rx; + let dy = (py as f32 + 0.5 - cy) / ry; + if dx * dx + dy * dy > 1.0 { + continue; + } + + let inside_inner = if inner_rx > 0.0 && inner_ry > 0.0 { + let ix = (px as f32 + 0.5 - cx) / inner_rx; + let iy = (py as f32 + 0.5 - cy) / inner_ry; + ix * ix + iy * iy <= 1.0 + } else { + false + }; + + if !inside_inner { + canvas.put_pixel(px as u32, py as u32, color); + } + } + } + } + fn draw_diagonal_line( &self, canvas: &mut RgbaImage, @@ -451,17 +496,41 @@ impl Renderer { continue; } let val = (gf.data[idx] >> (7 - x % 8)) & 1; - if val != 0 { - for my in 0..mag_y { - for mx in 0..mag_x { - let px = base_x + x * mag_x + mx; - let py = base_y + y * mag_y + my; - if px >= 0 - && py >= 0 - && (px as u32) < canvas.width() - && (py as u32) < canvas.height() - { - canvas.put_pixel(px as u32, py as u32, black); + for my in 0..mag_y { + for mx in 0..mag_x { + let px = base_x + x * mag_x + mx; + let py = base_y + y * mag_y + my; + if px < 0 + || py < 0 + || (px as u32) >= canvas.width() + || (py as u32) >= canvas.height() + { + continue; + } + + match gf.mode { + crate::elements::graphic_field::GraphicFieldMode::Or => { + if val != 0 { + canvas.put_pixel(px as u32, py as u32, black); + } + } + crate::elements::graphic_field::GraphicFieldMode::Overwrite => { + let color = if val != 0 { + black + } else { + Rgba([255, 255, 255, 255]) + }; + canvas.put_pixel(px as u32, py as u32, color); + } + crate::elements::graphic_field::GraphicFieldMode::Xor => { + if val != 0 { + let bg = *canvas.get_pixel(px as u32, py as u32); + canvas.put_pixel( + px as u32, + py as u32, + Rgba([255 - bg[0], 255 - bg[1], 255 - bg[2], bg[3]]), + ); + } } } } @@ -692,7 +761,13 @@ impl Renderer { (pos.x - quiet_zone_px, pos.y + bc.height - quiet_zone_px) }; - overlay_at(canvas, &img, draw_x, draw_y); + let draw_pos = LabelPosition { + x: draw_x, + y: draw_y, + calculate_from_bottom: false, + automatic_position: false, + }; + overlay_with_rotation(canvas, &img, &draw_pos, bc.barcode.orientation); Ok(()) } @@ -877,6 +952,7 @@ fn get_text_top_left_pos( // ^FO: position is top-left of the field area. Handle justification parameter. let x = match text.alignment { crate::elements::field_alignment::FieldAlignment::Right => x - w, + crate::elements::field_alignment::FieldAlignment::Center => x - w / 2.0, _ => x, }; return (x, y); diff --git a/src/elements/barcode_qr.rs b/src/elements/barcode_qr.rs index b99b51c..faf9edc 100644 --- a/src/elements/barcode_qr.rs +++ b/src/elements/barcode_qr.rs @@ -1,3 +1,4 @@ +use super::field_orientation::FieldOrientation; use super::label_position::LabelPosition; use super::reverse_print::ReversePrint; @@ -21,6 +22,7 @@ pub enum QrCharacterMode { #[derive(Clone, Debug)] pub struct BarcodeQr { pub magnification: i32, + pub orientation: FieldOrientation, } #[derive(Clone, Debug)] diff --git a/src/elements/field_alignment.rs b/src/elements/field_alignment.rs index 148d2ba..740a347 100644 --- a/src/elements/field_alignment.rs +++ b/src/elements/field_alignment.rs @@ -4,4 +4,5 @@ pub enum FieldAlignment { Left = 0, Right = 1, Auto = 2, + Center = 3, } diff --git a/src/elements/graphic_ellipse.rs b/src/elements/graphic_ellipse.rs new file mode 100644 index 0000000..733fb2a --- /dev/null +++ b/src/elements/graphic_ellipse.rs @@ -0,0 +1,13 @@ +use super::label_position::LabelPosition; +use super::line_color::LineColor; +use super::reverse_print::ReversePrint; + +#[derive(Clone, Debug)] +pub struct GraphicEllipse { + pub reverse_print: ReversePrint, + pub position: LabelPosition, + pub width: i32, + pub height: i32, + pub border_thickness: i32, + pub line_color: LineColor, +} diff --git a/src/elements/graphic_field.rs b/src/elements/graphic_field.rs index 17d474a..10114b1 100644 --- a/src/elements/graphic_field.rs +++ b/src/elements/graphic_field.rs @@ -8,11 +8,19 @@ pub enum GraphicFieldFormat { AR = 3, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraphicFieldMode { + Overwrite, + Or, + Xor, +} + #[derive(Clone, Debug)] pub struct GraphicField { pub reverse_print: ReversePrint, pub position: LabelPosition, pub format: GraphicFieldFormat, + pub mode: GraphicFieldMode, pub data_bytes: i32, pub total_bytes: i32, pub row_bytes: i32, diff --git a/src/elements/label_element.rs b/src/elements/label_element.rs index 6f08724..9f6251f 100644 --- a/src/elements/label_element.rs +++ b/src/elements/label_element.rs @@ -10,6 +10,7 @@ use super::field_block::FieldBlock; use super::graphic_box::GraphicBox; use super::graphic_circle::GraphicCircle; use super::graphic_diagonal_line::GraphicDiagonalLine; +use super::graphic_ellipse::GraphicEllipse; use super::graphic_field::GraphicField; use super::graphic_symbol::GraphicSymbol; use super::maxicode::{Maxicode, MaxicodeWithData}; @@ -23,6 +24,7 @@ pub enum LabelElement { Text(TextField), GraphicBox(GraphicBox), GraphicCircle(GraphicCircle), + GraphicEllipse(GraphicEllipse), DiagonalLine(GraphicDiagonalLine), GraphicField(GraphicField), Barcode128(Barcode128WithData), @@ -61,6 +63,7 @@ impl LabelElement { LabelElement::Text(t) => t.reverse_print.value, LabelElement::GraphicBox(g) => g.reverse_print.value, LabelElement::GraphicCircle(g) => g.reverse_print.value, + LabelElement::GraphicEllipse(g) => g.reverse_print.value, LabelElement::DiagonalLine(g) => g.reverse_print.value, LabelElement::GraphicField(g) => g.reverse_print.value, LabelElement::Barcode128(b) => b.reverse_print.value, diff --git a/src/elements/mod.rs b/src/elements/mod.rs index 8641d23..b9b3d42 100644 --- a/src/elements/mod.rs +++ b/src/elements/mod.rs @@ -16,6 +16,7 @@ pub mod font; pub mod graphic_box; pub mod graphic_circle; pub mod graphic_diagonal_line; +pub mod graphic_ellipse; pub mod graphic_field; pub mod graphic_symbol; pub mod label_element; diff --git a/src/lib.rs b/src/lib.rs index f34c948..8f0a0ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,4 +17,5 @@ pub use error::LabelizeError; pub use images::monochrome::encode_png; pub use images::pdf::encode_pdf; pub use parsers::epl_parser::EplParser; +pub use parsers::tspl_parser::{TsplParsedLabel, TsplParser}; pub use parsers::zpl_parser::ZplParser; diff --git a/src/main.rs b/src/main.rs index 5bdebb5..8587a7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,23 +3,26 @@ use std::io::Cursor; use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; -use labelize::{DrawerOptions, EplParser, LabelInfo, Renderer, ZplParser}; +use labelize::{ + DrawerOptions, EplParser, LabelInfo, Renderer, TsplParsedLabel, TsplParser, ZplParser, +}; #[derive(Parser)] #[command( name = "labelize", version, - about = "Turn ZPL/EPL into pixels — label rendering, simplified." + about = "Turn ZPL/EPL/TSPL into pixels — label rendering, simplified." )] struct Cli { #[command(subcommand)] command: Commands, } -#[derive(Clone, Copy, ValueEnum)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] enum InputFormat { Zpl, Epl, + Tspl, } #[derive(Clone, Copy, ValueEnum)] @@ -30,9 +33,9 @@ enum OutputType { #[derive(Subcommand)] enum Commands { - /// Convert a ZPL/EPL file to PNG or PDF + /// Convert a ZPL/EPL/TSPL file to PNG or PDF Convert { - /// Input file path (.zpl or .epl) + /// Input file path (.zpl, .epl, or .tspl) input: PathBuf, /// Output file path (default: input stem + .png/.pdf) @@ -47,13 +50,13 @@ enum Commands { #[arg(short = 't', long = "type", default_value = "png")] output_type: OutputType, - /// Label width in mm - #[arg(long, default_value_t = 102.0)] - width: f64, + /// Label width override in mm + #[arg(long)] + width: Option, - /// Label height in mm - #[arg(long, default_value_t = 152.0)] - height: f64, + /// Label height override in mm + #[arg(long)] + height: Option, /// Dots per mm (6, 8, 12, or 24) #[arg(long, default_value_t = 8)] @@ -112,17 +115,90 @@ fn detect_format(path: &Path, override_fmt: Option) -> InputFormat let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); match ext.to_lowercase().as_str() { "epl" => InputFormat::Epl, + "tspl" => InputFormat::Tspl, _ => InputFormat::Zpl, } } +fn detect_format_from_content_type(content_type: &str) -> InputFormat { + let content_type = content_type.to_ascii_lowercase(); + if content_type.contains("tspl") { + InputFormat::Tspl + } else if content_type.contains("epl") { + InputFormat::Epl + } else { + InputFormat::Zpl + } +} + +fn default_width() -> f64 { + 102.0 +} + +fn default_height() -> f64 { + 152.0 +} + +fn default_dpmm() -> i32 { + 8 +} + fn parse_labels(content: &[u8], format: InputFormat) -> Result, String> { match format { InputFormat::Epl => EplParser::new().parse(content), + InputFormat::Tspl => TsplParser::new().parse(content), InputFormat::Zpl => ZplParser::new().parse(content), } } +fn parse_labels_with_options( + content: &[u8], + format: InputFormat, + base_options: DrawerOptions, + width_override: Option, + height_override: Option, +) -> Result, String> { + match format { + InputFormat::Tspl => Ok(TsplParser::new() + .parse_with_options(content, base_options)? + .into_iter() + .map(|mut parsed| { + parsed.drawer_options = apply_dimension_overrides( + parsed.drawer_options, + width_override, + height_override, + ); + parsed + }) + .collect()), + _ => Ok(parse_labels(content, format)? + .into_iter() + .map(|label| TsplParsedLabel { + label, + drawer_options: apply_dimension_overrides( + base_options.clone(), + width_override, + height_override, + ), + }) + .collect()), + } +} + +fn apply_dimension_overrides( + mut options: DrawerOptions, + width_override: Option, + height_override: Option, +) -> DrawerOptions { + if let Some(width) = width_override { + options.label_width_mm = width; + } + if let Some(height) = height_override { + options.label_height_mm = height; + } + options +} + fn output_extension(output_type: OutputType) -> &'static str { match output_type { OutputType::Png => "png", @@ -171,28 +247,27 @@ fn convert_file( output: Option<&Path>, format: Option, output_type: OutputType, - width: f64, - height: f64, + width: Option, + height: Option, dpmm: i32, ) -> Result<(), String> { let content = fs::read(input).map_err(|e| format!("Failed to read input file: {}", e))?; let fmt = detect_format(input, format); - let labels = parse_labels(&content, fmt)?; + let base_options = DrawerOptions { + label_width_mm: width.unwrap_or_else(default_width), + label_height_mm: height.unwrap_or_else(default_height), + dpmm, + ..Default::default() + }; + let labels = parse_labels_with_options(&content, fmt, base_options, width, height)?; if labels.is_empty() { return Err("No labels found in input".to_string()); } - let options = DrawerOptions { - label_width_mm: width, - label_height_mm: height, - dpmm, - ..Default::default() - }; - let multi = labels.len() > 1; - for (i, label) in labels.iter().enumerate() { + for (i, parsed) in labels.iter().enumerate() { let out_path = match output { Some(p) if !multi => p.to_path_buf(), Some(p) => { @@ -207,7 +282,7 @@ fn convert_file( None => default_output_path(input, output_type, if multi { Some(i) } else { None }), }; - let data = render_label(label, &options, output_type)?; + let data = render_label(&parsed.label, &parsed.drawer_options, output_type)?; fs::write(&out_path, data).map_err(|e| format!("Failed to write output file: {}", e))?; println!("Converted {} -> {}", input.display(), out_path.display()); } @@ -246,26 +321,16 @@ async fn serve(host: String, port: u16) { #[derive(serde::Deserialize)] struct ConvertParams { - #[serde(default = "default_width")] - width: f64, - #[serde(default = "default_height")] - height: f64, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, #[serde(default = "default_dpmm")] dpmm: i32, #[serde(default)] output: Option, } - fn default_width() -> f64 { - 102.0 - } - fn default_height() -> f64 { - 152.0 - } - fn default_dpmm() -> i32 { - 8 - } - async fn convert_handler( headers: HeaderMap, Query(params): Query, @@ -277,36 +342,35 @@ async fn serve(host: String, port: u16) { .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let labels = if content_type.contains("epl") { - EplParser::new().parse(&body) - } else { - ZplParser::new().parse(&body) + let format = detect_format_from_content_type(content_type); + let base_options = DrawerOptions { + label_width_mm: params.width.unwrap_or_else(default_width), + label_height_mm: params.height.unwrap_or_else(default_height), + dpmm: params.dpmm, + ..Default::default() }; + let labels = + parse_labels_with_options(&body, format, base_options, params.width, params.height); let labels = match labels { Ok(l) => l, Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(), }; - let label = match labels.into_iter().next() { + let parsed = match labels.into_iter().next() { Some(l) => l, None => { return (StatusCode::BAD_REQUEST, "No labels found".to_string()).into_response() } }; - let options = DrawerOptions { - label_width_mm: params.width, - label_height_mm: params.height, - dpmm: params.dpmm, - ..Default::default() - }; - let want_pdf = params.output.as_deref() == Some("pdf"); let renderer = Renderer::new(); let mut buf = Cursor::new(Vec::new()); - if let Err(e) = renderer.draw_label_as_png(&label, &mut buf, options.clone()) { + if let Err(e) = + renderer.draw_label_as_png(&parsed.label, &mut buf, parsed.drawer_options.clone()) + { return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); } @@ -322,7 +386,7 @@ async fn serve(host: String, port: u16) { } }; let mut pdf_buf = Cursor::new(Vec::new()); - match labelize::encode_pdf(&img, &options, &mut pdf_buf) { + match labelize::encode_pdf(&img, &parsed.drawer_options, &mut pdf_buf) { Ok(_) => ( StatusCode::OK, [(header::CONTENT_TYPE, "application/pdf")], @@ -357,3 +421,58 @@ async fn serve(host: String, port: u16) { .expect("Failed to bind"); axum::serve(listener, app).await.expect("Server failed"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_format_supports_tspl_extension_and_override() { + assert_eq!( + detect_format(Path::new("label.tspl"), None), + InputFormat::Tspl + ); + assert_eq!( + detect_format(Path::new("label.txt"), Some(InputFormat::Tspl)), + InputFormat::Tspl + ); + } + + #[test] + fn content_type_detection_supports_tspl() { + assert_eq!( + detect_format_from_content_type("application/tspl; charset=utf-8"), + InputFormat::Tspl + ); + assert_eq!( + detect_format_from_content_type("text/vnd.tspl"), + InputFormat::Tspl + ); + assert_eq!( + detect_format_from_content_type("APPLICATION/TSPL"), + InputFormat::Tspl + ); + assert_eq!( + detect_format_from_content_type("application/epl"), + InputFormat::Epl + ); + } + + #[test] + fn explicit_dimensions_override_tspl_size_per_axis() { + let options = DrawerOptions { + label_width_mm: 50.0, + label_height_mm: 25.0, + dpmm: 8, + enable_inverted_labels: true, + }; + + let resolved = apply_dimension_overrides(options, Some(100.0), None); + assert_eq!(resolved.label_width_mm, 100.0); + assert_eq!(resolved.label_height_mm, 25.0); + + let resolved = apply_dimension_overrides(resolved, None, Some(75.0)); + assert_eq!(resolved.label_width_mm, 100.0); + assert_eq!(resolved.label_height_mm, 75.0); + } +} diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index 64bb3e4..deb7430 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -1,8 +1,10 @@ pub mod command_utils; pub mod epl_parser; pub mod fs; +pub mod tspl_parser; pub mod virtual_printer; pub mod zpl_parser; pub use epl_parser::EplParser; +pub use tspl_parser::{TsplParsedLabel, TsplParser}; pub use zpl_parser::ZplParser; diff --git a/src/parsers/tspl_parser.rs b/src/parsers/tspl_parser.rs new file mode 100644 index 0000000..257c519 --- /dev/null +++ b/src/parsers/tspl_parser.rs @@ -0,0 +1,858 @@ +use crate::elements::barcode_128::{Barcode128, Barcode128WithData, BarcodeMode}; +use crate::elements::barcode_39::{Barcode39, Barcode39WithData}; +use crate::elements::barcode_ean13::{BarcodeEan13, BarcodeEan13WithData}; +use crate::elements::barcode_pdf417::{BarcodePdf417, BarcodePdf417WithData}; +use crate::elements::barcode_qr::{BarcodeQr, BarcodeQrWithData}; +use crate::elements::drawer_options::DrawerOptions; +use crate::elements::field_alignment::FieldAlignment; +use crate::elements::field_orientation::FieldOrientation; +use crate::elements::font::FontInfo; +use crate::elements::graphic_box::GraphicBox; +use crate::elements::graphic_circle::GraphicCircle; +use crate::elements::graphic_ellipse::GraphicEllipse; +use crate::elements::graphic_field::{GraphicField, GraphicFieldFormat, GraphicFieldMode}; +use crate::elements::label_element::LabelElement; +use crate::elements::label_info::LabelInfo; +use crate::elements::label_position::LabelPosition; +use crate::elements::line_color::LineColor; +use crate::elements::reverse_print::ReversePrint; +use crate::elements::text_field::TextField; + +#[derive(Clone, Debug)] +pub struct TsplParsedLabel { + pub label: LabelInfo, + pub drawer_options: DrawerOptions, +} + +pub struct TsplParser; + +impl Default for TsplParser { + fn default() -> Self { + Self + } +} + +impl TsplParser { + pub fn new() -> Self { + Self + } + + pub fn parse(&self, tspl_data: &[u8]) -> Result, String> { + Ok(self + .parse_with_options(tspl_data, DrawerOptions::default())? + .into_iter() + .map(|parsed| parsed.label) + .collect()) + } + + pub fn parse_with_options( + &self, + tspl_data: &[u8], + base_options: DrawerOptions, + ) -> Result, String> { + let commands = split_tspl_commands(tspl_data)?; + let mut state = TsplState::new(base_options.with_defaults()); + let mut labels = Vec::new(); + + for command in commands { + match command { + TsplCommand::Line(line) => self.parse_line(&line, &mut state, &mut labels)?, + TsplCommand::Bitmap { header, data } => { + self.parse_bitmap(&header, data, &mut state)? + } + } + } + + if !state.elements.is_empty() { + labels.push(state.to_label()); + } + + Ok(labels) + } + + fn parse_line( + &self, + line: &str, + state: &mut TsplState, + labels: &mut Vec, + ) -> Result<(), String> { + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let name = command_name(line); + match name.as_str() { + "SIZE" => parse_size(line, state), + "DIRECTION" => parse_direction(line, state), + "REFERENCE" => parse_reference(line, state), + "SHIFT" => parse_shift(line, state), + "CLS" => { + state.elements.clear(); + Ok(()) + } + "PRINT" => { + if !state.elements.is_empty() { + labels.push(state.to_label()); + state.elements.clear(); + } + Ok(()) + } + "TEXT" => parse_text(line, state), + "BAR" => parse_bar(line, state), + "BOX" => parse_box(line, state), + "CIRCLE" => parse_circle(line, state), + "ELLIPSE" => parse_ellipse(line, state), + "ERASE" => parse_region_box(line, state, LineColor::White, false), + "REVERSE" => parse_region_box(line, state, LineColor::Black, true), + "BARCODE" => parse_barcode(line, state), + "QRCODE" => parse_qrcode(line, state), + "PDF417" => parse_pdf417(line, state), + "MPDF417" | "PUTPCX" | "PUTBMP" | "DMATRIX" | "AZTEC" => { + Err(format!("unsupported TSPL command {}", name)) + } + _ if is_device_noop(&name) => Ok(()), + _ => Ok(()), + } + } + + fn parse_bitmap( + &self, + header: &str, + data: Vec, + state: &mut TsplState, + ) -> Result<(), String> { + let args = command_args(header, "BITMAP"); + if args.len() < 5 { + return Err(format!( + "TSPL BITMAP command requires at least 5 parameters, got {}", + args.len() + )); + } + + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let row_bytes = parse_i32_arg(&args[2]).unwrap_or(1).max(1); + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + let mode = match parse_i32_arg(&args[4]).unwrap_or(0) { + 1 => GraphicFieldMode::Or, + 2 => GraphicFieldMode::Xor, + _ => GraphicFieldMode::Overwrite, + }; + let total_bytes = row_bytes * height; + + state + .elements + .push(LabelElement::GraphicField(GraphicField { + reverse_print: ReversePrint::default(), + position: state.position(x, y), + format: GraphicFieldFormat::Raw, + mode, + data_bytes: total_bytes, + total_bytes, + row_bytes, + data, + magnification_x: 1, + magnification_y: 1, + })); + Ok(()) + } +} + +struct TsplState { + elements: Vec, + options: DrawerOptions, + reference_x: i32, + reference_y: i32, + shift_x: i32, + shift_y: i32, + inverted: bool, +} + +impl TsplState { + fn new(options: DrawerOptions) -> Self { + Self { + elements: Vec::new(), + options, + reference_x: 0, + reference_y: 0, + shift_x: 0, + shift_y: 0, + inverted: false, + } + } + + fn position(&self, x: i32, y: i32) -> LabelPosition { + LabelPosition { + x: x + self.reference_x + self.shift_x, + y: y + self.reference_y + self.shift_y, + calculate_from_bottom: false, + automatic_position: false, + } + } + + fn to_label(&self) -> TsplParsedLabel { + TsplParsedLabel { + drawer_options: self.options.clone(), + label: LabelInfo { + print_width: 0, + inverted: self.inverted, + elements: self.elements.clone(), + }, + } + } +} + +enum TsplCommand { + Line(String), + Bitmap { header: String, data: Vec }, +} + +fn split_tspl_commands(data: &[u8]) -> Result, String> { + let mut commands = Vec::new(); + let mut idx = 0usize; + + while idx < data.len() { + while idx < data.len() && matches!(data[idx], b'\r' | b'\n') { + idx += 1; + } + if idx >= data.len() { + break; + } + + let line_start = idx; + let token_end = first_token_end(data, line_start); + let token = String::from_utf8_lossy(&data[line_start..token_end]).to_uppercase(); + + if token == "BITMAP" { + let payload_start = bitmap_payload_start(data, line_start) + .ok_or_else(|| "TSPL BITMAP command missing bitmap data separator".to_string())?; + let header = String::from_utf8_lossy(&data[line_start..payload_start]).to_string(); + let args = command_args(&header, "BITMAP"); + if args.len() < 4 { + return Err(format!( + "TSPL BITMAP command requires at least 4 size parameters, got {}", + args.len() + )); + } + let row_bytes = parse_i32_arg(&args[2]).unwrap_or(1).max(1) as usize; + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1) as usize; + let data_len = row_bytes * height; + let payload_end = payload_start.saturating_add(data_len).min(data.len()); + commands.push(TsplCommand::Bitmap { + header, + data: data[payload_start..payload_end].to_vec(), + }); + idx = payload_end; + while idx < data.len() && matches!(data[idx], b'\r' | b'\n') { + idx += 1; + } + continue; + } + + idx = command_line_end(data, line_start); + let line = String::from_utf8_lossy(&data[line_start..idx]).to_string(); + commands.push(TsplCommand::Line(line)); + } + + Ok(commands) +} + +fn first_token_end(data: &[u8], start: usize) -> usize { + let mut idx = start; + while idx < data.len() && !data[idx].is_ascii_whitespace() { + idx += 1; + } + idx +} + +fn bitmap_payload_start(data: &[u8], start: usize) -> Option { + let mut commas = 0usize; + let mut idx = start; + while idx < data.len() { + if data[idx] == b',' { + commas += 1; + if commas == 5 { + return Some(idx + 1); + } + } + if matches!(data[idx], b'\r' | b'\n') { + return None; + } + idx += 1; + } + None +} + +fn command_line_end(data: &[u8], start: usize) -> usize { + let mut idx = start; + let mut in_quotes = false; + + while idx < data.len() { + if data[idx] == b'"' && !is_escaped_quote_byte(data, idx) { + in_quotes = !in_quotes; + } else if matches!(data[idx], b'\r' | b'\n') && !in_quotes { + break; + } + idx += 1; + } + + idx +} + +fn is_escaped_quote_byte(data: &[u8], idx: usize) -> bool { + idx > 0 && data[idx - 1] == b'\\' + || idx >= 2 + && data[idx - 2] == b'\\' + && data[idx - 1] == b'[' + && data.get(idx + 1) == Some(&b']') +} + +fn command_name(line: &str) -> String { + line.split_whitespace() + .next() + .unwrap_or("") + .to_ascii_uppercase() +} + +fn command_args(line: &str, name: &str) -> Vec { + let rest = line.get(name.len()..).unwrap_or("").trim_start(); + split_tspl_args(rest) +} + +fn split_tspl_args(input: &str) -> Vec { + let chars: Vec = input.chars().collect(); + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut i = 0usize; + + while i < chars.len() { + let ch = chars[i]; + let escaped_quote = ch == '"' + && (i > 0 && chars[i - 1] == '\\' + || i >= 2 + && chars[i - 2] == '\\' + && chars[i - 1] == '[' + && chars.get(i + 1) == Some(&']')); + + if ch == '"' && !escaped_quote { + in_quotes = !in_quotes; + current.push(ch); + } else if ch == ',' && !in_quotes { + args.push(clean_arg(¤t)); + current.clear(); + } else { + current.push(ch); + } + i += 1; + } + + if !current.is_empty() || input.ends_with(',') { + args.push(clean_arg(¤t)); + } + + args +} + +fn clean_arg(arg: &str) -> String { + let trimmed = arg.trim(); + let unquoted = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + unquoted.replace("\\[\"]", "\"").replace("\\\"", "\"") +} + +fn parse_i32_arg(arg: &str) -> Option { + arg.trim().parse::().ok().map(|v| v.round() as i32) +} + +fn parse_f64_arg(arg: &str) -> Option { + arg.trim().parse::().ok() +} + +fn parse_size(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "SIZE"); + if args.is_empty() { + return Err("TSPL SIZE command requires at least a width".to_string()); + } + if let Some(width) = parse_dimension_mm(&args[0], state.options.dpmm) { + state.options.label_width_mm = width; + } + if let Some(height_arg) = args.get(1) { + if let Some(height) = parse_dimension_mm(height_arg, state.options.dpmm) { + state.options.label_height_mm = height; + } + } + Ok(()) +} + +fn parse_dimension_mm(arg: &str, dpmm: i32) -> Option { + let value_text: String = arg + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-') + .collect(); + let value = parse_f64_arg(&value_text)?; + let lower = arg.trim().to_ascii_lowercase(); + if lower.ends_with("mm") { + Some(value) + } else if lower.ends_with("dot") || lower.ends_with("dots") { + Some(value / dpmm.max(1) as f64) + } else { + Some(value * 25.4) + } +} + +fn parse_direction(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "DIRECTION"); + if let Some(v) = args.first().and_then(|arg| parse_i32_arg(arg)) { + state.inverted = v == 0; + } + Ok(()) +} + +fn parse_reference(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "REFERENCE"); + if let Some(v) = args.first().and_then(|arg| parse_i32_arg(arg)) { + state.reference_x = v; + } + if let Some(v) = args.get(1).and_then(|arg| parse_i32_arg(arg)) { + state.reference_y = v; + } + Ok(()) +} + +fn parse_shift(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "SHIFT"); + match args.as_slice() { + [y] => { + state.shift_y = parse_i32_arg(y).unwrap_or(0); + } + [x, y, ..] => { + state.shift_x = parse_i32_arg(x).unwrap_or(0); + state.shift_y = parse_i32_arg(y).unwrap_or(0); + } + _ => {} + } + Ok(()) +} + +fn parse_text(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "TEXT"); + if args.len() < 7 { + return Err(format!( + "TSPL TEXT command requires at least 7 parameters, got {}", + args.len() + )); + } + + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let (base_w, base_h) = tspl_font_size(&args[2]); + let orientation = tspl_rotation(&args[3]); + let x_mult = parse_i32_arg(&args[4]).unwrap_or(1).clamp(1, 10); + let y_mult = parse_i32_arg(&args[5]).unwrap_or(1).clamp(1, 10); + let (alignment, content_idx) = if args.len() >= 8 { + (tspl_alignment(&args[6]), 7usize) + } else { + (FieldAlignment::Left, 6usize) + }; + let text = args.get(content_idx).cloned().unwrap_or_default(); + if text.is_empty() { + return Ok(()); + } + + state.elements.push(LabelElement::Text(TextField { + reverse_print: ReversePrint::default(), + font: FontInfo { + name: "0".to_string(), + width: (base_w * x_mult) as f64, + height: (base_h * y_mult) as f64, + orientation, + }, + position: state.position(x, y), + alignment, + text, + block: None, + })); + Ok(()) +} + +fn tspl_font_size(font: &str) -> (i32, i32) { + match font.trim_matches('"') { + "2" => (12, 20), + "3" => (16, 24), + "4" => (24, 32), + "5" => (32, 48), + "6" => (14, 19), + "7" => (21, 27), + "8" => (14, 25), + _ => (8, 12), + } +} + +fn tspl_alignment(arg: &str) -> FieldAlignment { + match parse_i32_arg(arg).unwrap_or(0) { + 2 => FieldAlignment::Center, + 3 => FieldAlignment::Right, + _ => FieldAlignment::Left, + } +} + +fn parse_bar(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "BAR"); + if args.len() < 4 { + return Err(format!( + "TSPL BAR command requires 4 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let width = parse_i32_arg(&args[2]).unwrap_or(1).max(1); + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + state.elements.push(LabelElement::GraphicBox(filled_box( + state, + x, + y, + width, + height, + LineColor::Black, + false, + ))); + Ok(()) +} + +fn parse_box(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "BOX"); + if args.len() < 5 { + return Err(format!( + "TSPL BOX command requires at least 5 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let x_end = parse_i32_arg(&args[2]).unwrap_or(x); + let y_end = parse_i32_arg(&args[3]).unwrap_or(y); + let thickness = parse_i32_arg(&args[4]).unwrap_or(1).max(1); + let radius = args.get(5).and_then(|arg| parse_i32_arg(arg)).unwrap_or(0); + state.elements.push(LabelElement::GraphicBox(GraphicBox { + reverse_print: ReversePrint::default(), + position: state.position(x, y), + width: (x_end - x).abs().max(thickness), + height: (y_end - y).abs().max(thickness), + border_thickness: thickness, + corner_rounding: radius.clamp(0, 8), + line_color: LineColor::Black, + })); + Ok(()) +} + +fn parse_circle(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "CIRCLE"); + if args.len() < 4 { + return Err(format!( + "TSPL CIRCLE command requires 4 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let diameter = parse_i32_arg(&args[2]).unwrap_or(1).max(1); + let thickness = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + state + .elements + .push(LabelElement::GraphicCircle(GraphicCircle { + reverse_print: ReversePrint::default(), + position: state.position(x, y), + circle_diameter: diameter, + border_thickness: thickness, + line_color: LineColor::Black, + })); + Ok(()) +} + +fn parse_ellipse(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "ELLIPSE"); + if args.len() < 5 { + return Err(format!( + "TSPL ELLIPSE command requires 5 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let width = parse_i32_arg(&args[2]).unwrap_or(1).max(1); + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + let thickness = parse_i32_arg(&args[4]).unwrap_or(1).max(1); + state + .elements + .push(LabelElement::GraphicEllipse(GraphicEllipse { + reverse_print: ReversePrint::default(), + position: state.position(x, y), + width, + height, + border_thickness: thickness, + line_color: LineColor::Black, + })); + Ok(()) +} + +fn parse_region_box( + line: &str, + state: &mut TsplState, + color: LineColor, + reverse: bool, +) -> Result<(), String> { + let name = command_name(line); + let args = command_args(line, &name); + if args.len() < 4 { + return Err(format!( + "TSPL {} command requires 4 parameters, got {}", + name, + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let width = parse_i32_arg(&args[2]).unwrap_or(1).max(1); + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + state.elements.push(LabelElement::GraphicBox(filled_box( + state, x, y, width, height, color, reverse, + ))); + Ok(()) +} + +fn filled_box( + state: &TsplState, + x: i32, + y: i32, + width: i32, + height: i32, + color: LineColor, + reverse: bool, +) -> GraphicBox { + GraphicBox { + reverse_print: ReversePrint { value: reverse }, + position: state.position(x, y), + width, + height, + border_thickness: width.min(height).max(1), + corner_rounding: 0, + line_color: color, + } +} + +fn parse_barcode(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "BARCODE"); + if args.len() < 9 { + return Err(format!( + "TSPL BARCODE command requires at least 9 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let code_type = args[2].trim().to_ascii_uppercase(); + let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); + let line = parse_i32_arg(&args[4]).unwrap_or(0) > 0; + let orientation = tspl_rotation(&args[5]); + let narrow = parse_i32_arg(&args[6]).unwrap_or(1).max(1); + let wide = parse_i32_arg(&args[7]).unwrap_or(narrow).max(narrow); + let content_idx = if args.len() >= 10 { 9 } else { 8 }; + let data = args.get(content_idx).cloned().unwrap_or_default(); + let position = state.position(x, y); + let width_ratio = (wide as f64 / narrow as f64).max(1.0); + + let element = match code_type.as_str() { + "128" | "128M" => LabelElement::Barcode128(Barcode128WithData { + reverse_print: ReversePrint::default(), + barcode: Barcode128 { + orientation, + height, + line, + line_above: false, + check_digit: false, + mode: BarcodeMode::Automatic, + }, + width: narrow, + position, + data, + }), + "EAN128" | "EAN128M" => LabelElement::Barcode128(Barcode128WithData { + reverse_print: ReversePrint::default(), + barcode: Barcode128 { + orientation, + height, + line, + line_above: false, + check_digit: false, + mode: BarcodeMode::Ucc, + }, + width: narrow, + position, + data, + }), + "39" | "39C" => LabelElement::Barcode39(Barcode39WithData { + reverse_print: ReversePrint::default(), + barcode: Barcode39 { + orientation, + height, + line, + line_above: false, + check_digit: code_type == "39C", + }, + width: narrow, + width_ratio, + position, + data, + }), + "EAN13" => LabelElement::BarcodeEan13(BarcodeEan13WithData { + reverse_print: ReversePrint::default(), + barcode: BarcodeEan13 { + orientation, + height, + line, + line_above: false, + }, + width: narrow, + position, + data, + }), + _ => return Err(format!("unsupported TSPL barcode type {}", code_type)), + }; + + state.elements.push(element); + Ok(()) +} + +fn parse_qrcode(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "QRCODE"); + if args.len() < 7 { + return Err(format!( + "TSPL QRCODE command requires at least 7 parameters, got {}", + args.len() + )); + } + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let level = args[2].chars().next().unwrap_or('H').to_ascii_uppercase(); + let magnification = parse_i32_arg(&args[3]).unwrap_or(1).clamp(1, 10); + let mode = args[4].chars().next().unwrap_or('A').to_ascii_uppercase(); + let orientation = tspl_rotation(&args[5]); + let content = args.last().cloned().unwrap_or_default(); + + state + .elements + .push(LabelElement::BarcodeQr(BarcodeQrWithData { + reverse_print: ReversePrint::default(), + barcode: BarcodeQr { + magnification, + orientation, + }, + height: 0, + position: state.position(x, y), + data: format!("{}{},{}", level, mode, content), + })); + Ok(()) +} + +fn parse_pdf417(line: &str, state: &mut TsplState) -> Result<(), String> { + let args = command_args(line, "PDF417"); + if args.len() < 6 { + return Err(format!( + "TSPL PDF417 command requires at least 6 parameters, got {}", + args.len() + )); + } + + let x = parse_i32_arg(&args[0]).unwrap_or(0); + let y = parse_i32_arg(&args[1]).unwrap_or(0); + let expected_height = parse_i32_arg(&args[3]).unwrap_or(10).max(1); + let orientation = tspl_rotation(&args[4]); + let content = args.last().cloned().unwrap_or_default(); + let mut barcode = BarcodePdf417 { + orientation, + row_height: 0, + security: 0, + columns: 0, + rows: 0, + truncate: false, + module_width: 2, + by_height: expected_height, + }; + + for option in args.iter().skip(5).take(args.len().saturating_sub(6)) { + apply_pdf417_option(option, &mut barcode); + } + + state + .elements + .push(LabelElement::BarcodePdf417(BarcodePdf417WithData { + reverse_print: ReversePrint::default(), + barcode, + position: state.position(x, y), + data: content, + })); + Ok(()) +} + +fn apply_pdf417_option(option: &str, barcode: &mut BarcodePdf417) { + let upper = option.trim().to_ascii_uppercase(); + if upper.is_empty() { + return; + } + let value = upper + .get(1..) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + match upper.as_bytes()[0] { + b'E' => barcode.security = value.clamp(0, 8), + b'W' => barcode.module_width = value.max(1), + b'H' => barcode.row_height = value.max(1), + b'R' => barcode.rows = value, + b'C' => barcode.columns = value, + b'T' => barcode.truncate = value == 1, + _ => {} + } +} + +fn tspl_rotation(arg: &str) -> FieldOrientation { + match parse_i32_arg(arg).unwrap_or(0) { + 1 | 90 => FieldOrientation::Rotated90, + 2 | 180 => FieldOrientation::Rotated180, + 3 | 270 => FieldOrientation::Rotated270, + _ => FieldOrientation::Normal, + } +} + +fn is_device_noop(name: &str) -> bool { + matches!( + name, + "GAP" + | "BLINE" + | "GAPDETECT" + | "BLINEDETECT" + | "AUTODETECT" + | "OFFSET" + | "SPEED" + | "DENSITY" + | "CODEPAGE" + | "FEED" + | "BACKFEED" + | "FORMFEED" + | "HOME" + | "SELFTEST" + | "INITIALPRINTER" + | "DOWNLOAD" + | "EOP" + | "FILES" + | "KILL" + | "RUN" + | "OUT" + | "SET" + ) +} diff --git a/tests/unit_tspl_parser.rs b/tests/unit_tspl_parser.rs new file mode 100644 index 0000000..2062030 --- /dev/null +++ b/tests/unit_tspl_parser.rs @@ -0,0 +1,260 @@ +use labelize::elements::field_alignment::FieldAlignment; +use labelize::elements::field_orientation::FieldOrientation; +use labelize::elements::graphic_field::GraphicFieldMode; +use labelize::elements::label_element::LabelElement; +use labelize::{DrawerOptions, TsplParser}; + +fn base_options() -> DrawerOptions { + DrawerOptions { + label_width_mm: 102.0, + label_height_mm: 152.0, + dpmm: 8, + enable_inverted_labels: true, + } +} + +fn parse_with_options(tspl: &[u8]) -> Vec { + TsplParser::new() + .parse_with_options(tspl, base_options()) + .expect("TSPL parse failed") +} + +#[test] +fn size_updates_drawer_options_without_touching_label_info() { + let labels = parse_with_options( + br#"SIZE 100 mm,50 mm +CLS +TEXT 10,20,"3",0,1,1,"Hello" +PRINT 1 +"#, + ); + + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].label.print_width, 0); + assert!((labels[0].drawer_options.label_width_mm - 100.0).abs() < f64::EPSILON); + assert!((labels[0].drawer_options.label_height_mm - 50.0).abs() < f64::EPSILON); +} + +#[test] +fn size_supports_inches_and_dots() { + let labels = parse_with_options( + br#"SIZE 2,1 +CLS +TEXT 0,0,"1",0,1,1,"A" +PRINT 1 +SIZE 812 dot,406 dot +CLS +TEXT 0,0,"1",0,1,1,"B" +PRINT 1 +"#, + ); + + assert_eq!(labels.len(), 2); + assert!((labels[0].drawer_options.label_width_mm - 50.8).abs() < 0.001); + assert!((labels[0].drawer_options.label_height_mm - 25.4).abs() < 0.001); + assert!((labels[1].drawer_options.label_width_mm - 101.5).abs() < 0.001); + assert!((labels[1].drawer_options.label_height_mm - 50.75).abs() < 0.001); +} + +#[test] +fn text_maps_font_rotation_alignment_and_reference_offsets() { + let labels = parse_with_options( + br#"SIZE 4,2 +REFERENCE 10,20 +SHIFT 3,4 +CLS +TEXT 100,50,"3",90,2,3,2,"Aligned" +PRINT 1 +"#, + ); + + let text = match &labels[0].label.elements[0] { + LabelElement::Text(text) => text, + other => panic!("expected Text, got {:?}", other), + }; + assert_eq!(text.text, "Aligned"); + assert_eq!(text.position.x, 113); + assert_eq!(text.position.y, 74); + assert_eq!(text.font.orientation, FieldOrientation::Rotated90); + assert_eq!(text.font.width, 32.0); + assert_eq!(text.font.height, 72.0); + assert_eq!(text.alignment, FieldAlignment::Center); +} + +#[test] +fn quoted_text_can_span_lines() { + let labels = parse_with_options( + br#"SIZE 4,2 +CLS +TEXT 25,25,"3",0,1,1,"FORMFEED COMMAND +TEXT" +PRINT 1 +"#, + ); + + let text = match &labels[0].label.elements[0] { + LabelElement::Text(text) => text, + other => panic!("expected Text, got {:?}", other), + }; + assert_eq!(text.text, "FORMFEED COMMAND\nTEXT"); +} + +#[test] +fn graphics_commands_map_to_renderable_elements() { + let labels = parse_with_options( + br#"SIZE 4,2 +CLS +BAR 1,2,30,4 +BOX 10,20,110,70,5,4 +CIRCLE 40,50,20,3 +ELLIPSE 60,70,80,30,2 +ERASE 5,6,7,8 +REVERSE 9,10,11,12 +PRINT 1 +"#, + ); + + assert_eq!(labels[0].label.elements.len(), 6); + match &labels[0].label.elements[0] { + LabelElement::GraphicBox(bar) => { + assert_eq!(bar.position.x, 1); + assert_eq!(bar.position.y, 2); + assert_eq!(bar.width, 30); + assert_eq!(bar.height, 4); + assert_eq!(bar.border_thickness, 4); + } + other => panic!("expected BAR GraphicBox, got {:?}", other), + } + match &labels[0].label.elements[1] { + LabelElement::GraphicBox(b) => { + assert_eq!(b.width, 100); + assert_eq!(b.height, 50); + assert_eq!(b.border_thickness, 5); + assert_eq!(b.corner_rounding, 4); + } + other => panic!("expected BOX GraphicBox, got {:?}", other), + } + assert!(matches!( + labels[0].label.elements[2], + LabelElement::GraphicCircle(_) + )); + assert!(matches!( + labels[0].label.elements[3], + LabelElement::GraphicEllipse(_) + )); + match &labels[0].label.elements[4] { + LabelElement::GraphicBox(erase) => { + assert_eq!(erase.width, 7); + assert_eq!(erase.height, 8); + } + other => panic!("expected ERASE GraphicBox, got {:?}", other), + } + match &labels[0].label.elements[5] { + LabelElement::GraphicBox(reverse) => { + assert!(reverse.reverse_print.value); + assert_eq!(reverse.width, 11); + assert_eq!(reverse.height, 12); + } + other => panic!("expected REVERSE GraphicBox, got {:?}", other), + } +} + +#[test] +fn bitmap_preserves_raw_bytes_and_mode() { + let labels = parse_with_options(b"SIZE 2,1\nCLS\nBITMAP 8,9,1,2,0,\x80\x01\nPRINT 1\n"); + + let bitmap = match &labels[0].label.elements[0] { + LabelElement::GraphicField(bitmap) => bitmap, + other => panic!("expected GraphicField, got {:?}", other), + }; + assert_eq!(bitmap.position.x, 8); + assert_eq!(bitmap.position.y, 9); + assert_eq!(bitmap.row_bytes, 1); + assert_eq!(bitmap.total_bytes, 2); + assert_eq!(bitmap.data, vec![0x80, 0x01]); + assert_eq!(bitmap.mode, GraphicFieldMode::Overwrite); +} + +#[test] +fn barcode_qrcode_and_pdf417_commands_map_to_existing_barcode_elements() { + let labels = parse_with_options( + br#"SIZE 4,2 +CLS +BARCODE 10,20,"128",60,1,90,2,2,"ABC123" +BARCODE 15,25,"39C",70,0,180,2,6,"CODE39" +QRCODE 30,40,H,4,A,270,"QR DATA" +PDF417 50,60,400,200,90,E3,W4,H5,R10,C6,T1,"PDF DATA" +PRINT 1 +"#, + ); + + match &labels[0].label.elements[0] { + LabelElement::Barcode128(bc) => { + assert_eq!(bc.data, "ABC123"); + assert_eq!(bc.position.x, 10); + assert_eq!(bc.barcode.height, 60); + assert_eq!(bc.barcode.orientation, FieldOrientation::Rotated90); + assert!(bc.barcode.line); + } + other => panic!("expected Barcode128, got {:?}", other), + } + match &labels[0].label.elements[1] { + LabelElement::Barcode39(bc) => { + assert_eq!(bc.data, "CODE39"); + assert_eq!(bc.barcode.orientation, FieldOrientation::Rotated180); + assert!(bc.barcode.check_digit); + assert!(!bc.barcode.line); + assert_eq!(bc.width_ratio, 3.0); + } + other => panic!("expected Barcode39, got {:?}", other), + } + match &labels[0].label.elements[2] { + LabelElement::BarcodeQr(qr) => { + assert_eq!(qr.data, "HA,QR DATA"); + assert_eq!(qr.barcode.magnification, 4); + assert_eq!(qr.barcode.orientation, FieldOrientation::Rotated270); + } + other => panic!("expected BarcodeQr, got {:?}", other), + } + match &labels[0].label.elements[3] { + LabelElement::BarcodePdf417(pdf) => { + assert_eq!(pdf.data, "PDF DATA"); + assert_eq!(pdf.barcode.orientation, FieldOrientation::Rotated90); + assert_eq!(pdf.barcode.security, 3); + assert_eq!(pdf.barcode.module_width, 4); + assert_eq!(pdf.barcode.row_height, 5); + assert_eq!(pdf.barcode.by_height, 200); + assert_eq!(pdf.barcode.rows, 10); + assert_eq!(pdf.barcode.columns, 6); + assert!(pdf.barcode.truncate); + } + other => panic!("expected BarcodePdf417, got {:?}", other), + } +} + +#[test] +fn unsupported_visible_commands_return_an_error_but_device_commands_are_ignored() { + let labels = parse_with_options( + br#"SIZE 4,2 +SPEED 4 +DENSITY 10 +SET TEAR ON +CLS +TEXT 0,0,"1",0,1,1,"A" +PRINT 1 +"#, + ); + assert_eq!(labels.len(), 1); + + let err = TsplParser::new() + .parse_with_options( + br#"SIZE 4,2 +CLS +MPDF417 10,10,0,"ABC" +PRINT 1 +"#, + base_options(), + ) + .expect_err("MPDF417 should be unsupported"); + assert!(err.contains("unsupported TSPL command MPDF417")); +} From ce6ffd68f0b5e259453490483774aaaa53040e40 Mon Sep 17 00:00:00 2001 From: cxm Date: Fri, 5 Jun 2026 16:15:30 +0800 Subject: [PATCH 02/12] fix: fix zpl parser compatible --- src/parsers/zpl_parser.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/parsers/zpl_parser.rs b/src/parsers/zpl_parser.rs index 16a5a9d..8561fbb 100644 --- a/src/parsers/zpl_parser.rs +++ b/src/parsers/zpl_parser.rs @@ -10,7 +10,7 @@ use crate::elements::field_block::FieldBlock; use crate::elements::graphic_box::GraphicBox; use crate::elements::graphic_circle::GraphicCircle; use crate::elements::graphic_diagonal_line::GraphicDiagonalLine; -use crate::elements::graphic_field::{GraphicField, GraphicFieldFormat}; +use crate::elements::graphic_field::{GraphicField, GraphicFieldFormat, GraphicFieldMode}; use crate::elements::graphic_symbol::GraphicSymbol; use crate::elements::label_element::LabelElement; use crate::elements::label_info::LabelInfo; @@ -784,7 +784,15 @@ impl ZplParser { fn parse_barcode_qr(&mut self, command: &str) { let parts = split_command(command, "^BQ"); - let mut bc = BarcodeQr { magnification: 1 }; + let mut bc = BarcodeQr { + magnification: 1, + orientation: self.printer.default_orientation, + }; + if let Some(s) = parts.first() { + if !s.is_empty() { + bc.orientation = to_field_orientation(s.as_bytes()[0]); + } + } if let Some(v) = parts.get(2).and_then(|s| parse_int(s)) { bc.magnification = v.clamp(1, 100); } @@ -915,6 +923,7 @@ impl ZplParser { magnification_y: 1, reverse_print: self.printer.get_reverse_print(), format: GraphicFieldFormat::Hex, + mode: GraphicFieldMode::Or, data_bytes: 0, total_bytes: 0, row_bytes: 0, @@ -1025,6 +1034,7 @@ impl ZplParser { magnification_y: 1, reverse_print: ReversePrint::default(), format: GraphicFieldFormat::Hex, + mode: GraphicFieldMode::Or, data_bytes: 0, total_bytes: 0, row_bytes: 0, @@ -1057,6 +1067,7 @@ impl ZplParser { magnification_y: 1, reverse_print: self.printer.get_reverse_print(), format: GraphicFieldFormat::Hex, + mode: GraphicFieldMode::Or, data_bytes: 0, total_bytes: 0, row_bytes: 0, From 813f4a30a657536fcd3d1b497d7bdc46479e01ef Mon Sep 17 00:00:00 2001 From: cxm Date: Fri, 5 Jun 2026 17:11:54 +0800 Subject: [PATCH 03/12] fix: barcode text alignment --- src/drawers/renderer.rs | 33 ++++++- src/elements/barcode_128.rs | 2 + src/elements/barcode_2of5.rs | 2 + src/elements/barcode_39.rs | 2 + src/elements/barcode_ean13.rs | 2 + src/parsers/epl_parser.rs | 5 + src/parsers/tspl_parser.rs | 8 +- src/parsers/zpl_parser.rs | 5 + src/playground.rs | 15 ++- tests/unit_renderer.rs | 168 ++++++++++++++++++++++++++++++++++ tests/unit_tspl_parser.rs | 60 ++++++++++++ 11 files changed, 291 insertions(+), 11 deletions(-) diff --git a/src/drawers/renderer.rs b/src/drawers/renderer.rs index 6d10b25..912b602 100644 --- a/src/drawers/renderer.rs +++ b/src/drawers/renderer.rs @@ -590,6 +590,7 @@ impl Renderer { &img, bc.barcode.orientation, bc.barcode.line_above, + bc.barcode.line_alignment, bc.width, ); } @@ -613,6 +614,7 @@ impl Renderer { &img, bc.barcode.orientation, bc.barcode.line_above, + bc.barcode.line_alignment, bc.width, ); } @@ -643,6 +645,7 @@ impl Renderer { &img, bc.barcode.orientation, bc.barcode.line_above, + bc.barcode.line_alignment, bc.width, ); } @@ -668,6 +671,7 @@ impl Renderer { &img, bc.barcode.orientation, bc.barcode.line_above, + bc.barcode.line_alignment, bc.width, ); } @@ -1258,6 +1262,7 @@ fn draw_barcode_interpretation_line( barcode_img: &RgbaImage, orientation: FieldOrientation, line_above: bool, + alignment: crate::elements::field_alignment::FieldAlignment, module_width: i32, ) { let font_data = FONT_DEJAVU_MONO; @@ -1329,7 +1334,7 @@ fn draw_barcode_interpretation_line( match orientation { FieldOrientation::Normal => { - let cx = pos.x + (bw - text_width as i32) / 2; + let cx = aligned_interpretation_line_pos(pos.x, bw, text_width as i32, alignment); let ty = if line_above { pos.y - font_size as i32 - 2 } else { @@ -1353,10 +1358,10 @@ fn draw_barcode_interpretation_line( _ => buf, }; - // Position: center text along the barcode edge let (tx, ty) = match orientation { FieldOrientation::Rotated90 => { - let cy = pos.y + (bw - text_width as i32) / 2; + let cy = + aligned_interpretation_line_pos(pos.y, bw, text_width as i32, alignment); if line_above { (pos.x + bh + 2, cy) } else { @@ -1364,7 +1369,8 @@ fn draw_barcode_interpretation_line( } } FieldOrientation::Rotated180 => { - let cx = pos.x + (bw - text_width as i32) / 2; + let cx = + aligned_interpretation_line_pos(pos.x, bw, text_width as i32, alignment); if line_above { (cx, pos.y + bh + 2) } else { @@ -1372,7 +1378,8 @@ fn draw_barcode_interpretation_line( } } FieldOrientation::Rotated270 => { - let cy = pos.y + (bw - text_width as i32) / 2; + let cy = + aligned_interpretation_line_pos(pos.y, bw, text_width as i32, alignment); if line_above { (pos.x - rotated.width() as i32 - 2, cy) } else { @@ -1385,3 +1392,19 @@ fn draw_barcode_interpretation_line( } } } + +fn aligned_interpretation_line_pos( + origin: i32, + span: i32, + text_width: i32, + alignment: crate::elements::field_alignment::FieldAlignment, +) -> i32 { + match alignment { + crate::elements::field_alignment::FieldAlignment::Right => origin + span - text_width, + crate::elements::field_alignment::FieldAlignment::Center + | crate::elements::field_alignment::FieldAlignment::Auto => { + origin + (span - text_width) / 2 + } + crate::elements::field_alignment::FieldAlignment::Left => origin, + } +} diff --git a/src/elements/barcode_128.rs b/src/elements/barcode_128.rs index 9575a83..628415c 100644 --- a/src/elements/barcode_128.rs +++ b/src/elements/barcode_128.rs @@ -1,3 +1,4 @@ +use super::field_alignment::FieldAlignment; use super::field_orientation::FieldOrientation; use super::label_position::LabelPosition; use super::reverse_print::ReversePrint; @@ -17,6 +18,7 @@ pub struct Barcode128 { pub height: i32, pub line: bool, pub line_above: bool, + pub line_alignment: FieldAlignment, pub check_digit: bool, pub mode: BarcodeMode, } diff --git a/src/elements/barcode_2of5.rs b/src/elements/barcode_2of5.rs index 3516501..81a4889 100644 --- a/src/elements/barcode_2of5.rs +++ b/src/elements/barcode_2of5.rs @@ -1,3 +1,4 @@ +use super::field_alignment::FieldAlignment; use super::field_orientation::FieldOrientation; use super::label_position::LabelPosition; use super::reverse_print::ReversePrint; @@ -8,6 +9,7 @@ pub struct Barcode2of5 { pub height: i32, pub line: bool, pub line_above: bool, + pub line_alignment: FieldAlignment, pub check_digit: bool, } diff --git a/src/elements/barcode_39.rs b/src/elements/barcode_39.rs index 5feab0c..56ffea0 100644 --- a/src/elements/barcode_39.rs +++ b/src/elements/barcode_39.rs @@ -1,3 +1,4 @@ +use super::field_alignment::FieldAlignment; use super::field_orientation::FieldOrientation; use super::label_position::LabelPosition; use super::reverse_print::ReversePrint; @@ -8,6 +9,7 @@ pub struct Barcode39 { pub height: i32, pub line: bool, pub line_above: bool, + pub line_alignment: FieldAlignment, pub check_digit: bool, } diff --git a/src/elements/barcode_ean13.rs b/src/elements/barcode_ean13.rs index 21a7ef5..81fa016 100644 --- a/src/elements/barcode_ean13.rs +++ b/src/elements/barcode_ean13.rs @@ -1,3 +1,4 @@ +use super::field_alignment::FieldAlignment; use super::field_orientation::FieldOrientation; use super::label_position::LabelPosition; use super::reverse_print::ReversePrint; @@ -8,6 +9,7 @@ pub struct BarcodeEan13 { pub height: i32, pub line: bool, pub line_above: bool, + pub line_alignment: FieldAlignment, } #[derive(Clone, Debug)] diff --git a/src/parsers/epl_parser.rs b/src/parsers/epl_parser.rs index df472a3..e8411ba 100644 --- a/src/parsers/epl_parser.rs +++ b/src/parsers/epl_parser.rs @@ -2,6 +2,7 @@ use crate::elements::barcode_128::{Barcode128, Barcode128WithData, BarcodeMode}; use crate::elements::barcode_2of5::{Barcode2of5, Barcode2of5WithData}; use crate::elements::barcode_39::{Barcode39, Barcode39WithData}; use crate::elements::barcode_ean13::{BarcodeEan13, BarcodeEan13WithData}; +use crate::elements::field_alignment::FieldAlignment; use crate::elements::field_orientation::FieldOrientation; use crate::elements::font::FontInfo; use crate::elements::graphic_box::GraphicBox; @@ -254,6 +255,7 @@ fn parse_epl_barcode(line: &str, ref_x: i32, ref_y: i32) -> Result Result Result Result Result<(), String> { let y = parse_i32_arg(&args[1]).unwrap_or(0); let code_type = args[2].trim().to_ascii_uppercase(); let height = parse_i32_arg(&args[3]).unwrap_or(1).max(1); - let line = parse_i32_arg(&args[4]).unwrap_or(0) > 0; + let human_readable = parse_i32_arg(&args[4]).unwrap_or(0); + let line = human_readable > 0; + let line_alignment = tspl_alignment(&args[4]); let orientation = tspl_rotation(&args[5]); let narrow = parse_i32_arg(&args[6]).unwrap_or(1).max(1); let wide = parse_i32_arg(&args[7]).unwrap_or(narrow).max(narrow); @@ -675,6 +677,7 @@ fn parse_barcode(line: &str, state: &mut TsplState) -> Result<(), String> { height, line, line_above: false, + line_alignment, check_digit: false, mode: BarcodeMode::Automatic, }, @@ -689,6 +692,7 @@ fn parse_barcode(line: &str, state: &mut TsplState) -> Result<(), String> { height, line, line_above: false, + line_alignment, check_digit: false, mode: BarcodeMode::Ucc, }, @@ -703,6 +707,7 @@ fn parse_barcode(line: &str, state: &mut TsplState) -> Result<(), String> { height, line, line_above: false, + line_alignment, check_digit: code_type == "39C", }, width: narrow, @@ -717,6 +722,7 @@ fn parse_barcode(line: &str, state: &mut TsplState) -> Result<(), String> { height, line, line_above: false, + line_alignment, }, width: narrow, position, diff --git a/src/parsers/zpl_parser.rs b/src/parsers/zpl_parser.rs index 8561fbb..173c19c 100644 --- a/src/parsers/zpl_parser.rs +++ b/src/parsers/zpl_parser.rs @@ -6,6 +6,7 @@ use crate::elements::barcode_datamatrix::{BarcodeDatamatrix, DatamatrixRatio}; use crate::elements::barcode_ean13::BarcodeEan13; use crate::elements::barcode_pdf417::BarcodePdf417; use crate::elements::barcode_qr::BarcodeQr; +use crate::elements::field_alignment::FieldAlignment; use crate::elements::field_block::FieldBlock; use crate::elements::graphic_box::GraphicBox; use crate::elements::graphic_circle::GraphicCircle; @@ -536,6 +537,7 @@ impl ZplParser { height: self.printer.default_barcode_dimensions.height, line: true, line_above: false, + line_alignment: FieldAlignment::Center, check_digit: false, mode: BarcodeMode::No, }; @@ -578,6 +580,7 @@ impl ZplParser { height: self.printer.default_barcode_dimensions.height, line: true, line_above: false, + line_alignment: FieldAlignment::Center, }; if let Some(s) = parts.first() { if !s.is_empty() { @@ -608,6 +611,7 @@ impl ZplParser { height: self.printer.default_barcode_dimensions.height, line: true, line_above: false, + line_alignment: FieldAlignment::Center, check_digit: false, }; if let Some(s) = parts.first() { @@ -644,6 +648,7 @@ impl ZplParser { height: self.printer.default_barcode_dimensions.height, line: true, line_above: false, + line_alignment: FieldAlignment::Center, check_digit: false, }; if let Some(s) = parts.first() { diff --git a/src/playground.rs b/src/playground.rs index 592da15..7263cda 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -302,6 +302,7 @@ pub const PLAYGROUND_HTML: &str = r##" @@ -337,7 +338,7 @@ pub const PLAYGROUND_HTML: &str = r##" - +