Skip to main content

zpl_builder/
lib.rs

1//! A library for building [ZPL II](https://www.zebra.com/us/en/support-downloads/software/zpl-resources.html)
2//! label strings to be sent to Zebra thermal printers.
3//!
4//! # Overview
5//!
6//! ZPL (Zebra Programming Language) is a label description language interpreted
7//! by Zebra printers. This library lets you build valid ZPL strings in Rust
8//! without constructing raw strings by hand. All field values are validated
9//! against the ZPL II specification at construction time — an invalid value
10//! returns a [`ZplError`] rather than silently producing a malformed command.
11//!
12//! # Quick start
13//!
14//! ```rust
15//! use zpl_builder::{LabelBuilder, BarcodeType, Color, Orientation};
16//!
17//! let zpl = LabelBuilder::new()
18//!     .set_home_position(10, 10).unwrap()
19//!     .add_text("Hello, printer!", 50, 50, 'A', 30, Orientation::Normal).unwrap()
20//!     .add_graphical_box(10, 10, 500, 300, 3, Color::Black, 0).unwrap()
21//!     .add_barcode(BarcodeType::Code128, "ABC-123", 50, 120, 2, 2.5, 80, Orientation::Normal).unwrap()
22//!     .build();
23//!
24//! assert!(zpl.starts_with("^XA"));
25//! assert!(zpl.ends_with("^XZ"));
26//! ```
27//!
28//! # Elements
29//!
30//! The builder supports the following ZPL elements:
31//!
32//! | Method | ZPL command | Description |
33//! |---|---|---|
34//! | [`LabelBuilder::add_text`] | `^A` / `^FD` | Text field |
35//! | [`LabelBuilder::add_barcode`] | `^BY` / `^B*` | Barcode (27 types) |
36//! | [`LabelBuilder::add_graphical_box`] | `^GB` | Rectangle |
37//! | [`LabelBuilder::add_graphical_circle`] | `^GC` | Circle |
38//! | [`LabelBuilder::add_graphical_ellipse`] | `^GE` | Ellipse |
39//! | [`LabelBuilder::add_graphical_diagonal_line`] | `^GD` | Diagonal line |
40//!
41//! # Error handling
42//!
43//! Every builder method returns `Result<LabelBuilder, ZplError>`. This means
44//! you can chain calls with `?` in a function that returns `Result`:
45//!
46//! ```rust
47//! use zpl_builder::{LabelBuilder, Color, Orientation, ZplError};
48//!
49//! fn build_shipping_label(tracking: &str) -> Result<String, ZplError> {
50//!     Ok(LabelBuilder::new()
51//!         .set_home_position(0, 0)?
52//!         .add_text("SHIP TO:", 10, 10, 'B', 20, Orientation::Normal)?
53//!         .add_graphical_box(5, 5, 400, 200, 2, Color::Black, 0)?
54//!         .build())
55//! }
56//! ```
57//!
58//! Or with `.unwrap()` / `.expect()` when the values are known to be valid:
59//!
60//! ```rust
61//! use zpl_builder::{LabelBuilder, Orientation};
62//!
63//! let zpl = LabelBuilder::new()
64//!     .add_text("Fixed label", 10, 10, 'A', 20, Orientation::Normal).unwrap()
65//!     .build();
66//! ```
67//!
68//! See [`ZplError`] for the full list of validation errors and their ranges.
69
70#![deny(
71    missing_docs,
72    missing_debug_implementations,
73    missing_copy_implementations,
74    trivial_casts,
75    trivial_numeric_casts,
76    unsafe_code,
77    unstable_features,
78    unused_import_braces,
79    unused_qualifications
80)]
81
82mod builder;
83mod elements;
84mod types;
85
86pub use crate::builder::{BarcodeType, Color, DiagonalOrientation, LabelBuilder, Orientation};
87pub use crate::types::ZplError;
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::types::{AxisPosition, BoxDimension, GraphicDimension, Thickness, WidthRatio};
93
94    // ── AxisPosition ──────────────────────────────────────────────────────────
95
96    #[test]
97    fn axis_position_valid_bounds() {
98        assert_eq!(AxisPosition::try_from(0), Ok(AxisPosition(0)));
99        assert_eq!(AxisPosition::try_from(32000), Ok(AxisPosition(32000)));
100        assert_eq!(AxisPosition::try_from(1000), Ok(AxisPosition(1000)));
101    }
102
103    #[test]
104    fn axis_position_out_of_range() {
105        assert_eq!(
106            AxisPosition::try_from(32001),
107            Err(ZplError::InvalidPosition(32001))
108        );
109        assert_eq!(
110            AxisPosition::try_from(u16::MAX),
111            Err(ZplError::InvalidPosition(u16::MAX))
112        );
113    }
114
115    // ── WidthRatio ────────────────────────────────────────────────────────────
116
117    #[test]
118    fn width_ratio_valid_values() {
119        assert_eq!(WidthRatio::try_from(2.0).unwrap().to_string(), "2.0");
120        assert_eq!(WidthRatio::try_from(2.5).unwrap().to_string(), "2.5");
121        assert_eq!(WidthRatio::try_from(3.0).unwrap().to_string(), "3.0");
122    }
123
124    #[test]
125    fn width_ratio_out_of_range() {
126        assert_eq!(
127            WidthRatio::try_from(1.9),
128            Err(ZplError::InvalidWidthRatio(1.9))
129        );
130        assert_eq!(
131            WidthRatio::try_from(3.1),
132            Err(ZplError::InvalidWidthRatio(3.1))
133        );
134    }
135
136    // ── GraphicDimension ──────────────────────────────────────────────────────
137
138    #[test]
139    fn graphic_dimension_valid_bounds() {
140        assert_eq!(GraphicDimension::try_from(3), Ok(GraphicDimension(3)));
141        assert_eq!(GraphicDimension::try_from(4095), Ok(GraphicDimension(4095)));
142    }
143
144    #[test]
145    fn graphic_dimension_out_of_range() {
146        assert_eq!(
147            GraphicDimension::try_from(2),
148            Err(ZplError::InvalidGraphicDimension(2))
149        );
150        assert_eq!(
151            GraphicDimension::try_from(4096),
152            Err(ZplError::InvalidGraphicDimension(4096))
153        );
154    }
155
156    // ── BoxDimension ──────────────────────────────────────────────────────────
157
158    #[test]
159    fn box_dimension_equal_to_thickness() {
160        let t = Thickness::try_from(10).unwrap();
161        assert_eq!(BoxDimension::try_new(10, t), Ok(BoxDimension(10)));
162    }
163
164    #[test]
165    fn box_dimension_greater_than_thickness() {
166        let t = Thickness::try_from(10).unwrap();
167        assert_eq!(BoxDimension::try_new(200, t), Ok(BoxDimension(200)));
168    }
169
170    #[test]
171    fn box_dimension_less_than_thickness() {
172        let t = Thickness::try_from(10).unwrap();
173        assert_eq!(
174            BoxDimension::try_new(9, t),
175            Err(ZplError::InvalidBoxDimension {
176                value: 9,
177                thickness: 10
178            })
179        );
180    }
181
182    #[test]
183    fn box_dimension_at_maximum() {
184        let t = Thickness::try_from(1).unwrap();
185        assert_eq!(BoxDimension::try_new(32000, t), Ok(BoxDimension(32000)));
186        assert_eq!(
187            BoxDimension::try_new(32001, t),
188            Err(ZplError::InvalidBoxDimension {
189                value: 32001,
190                thickness: 1
191            })
192        );
193    }
194
195    // ── add_text ──────────────────────────────────────────────────────────────
196
197    #[test]
198    fn add_text_normal_orientation() {
199        let label = LabelBuilder::new()
200            .add_text("Text to print.", 10, 10, 'B', 15, Orientation::Normal)
201            .unwrap()
202            .build();
203        assert_eq!(
204            label,
205            "^XA\n^LH0,0\n^AB,15^FO10,10^FDText to print.^FS\n^XZ"
206        );
207    }
208
209    #[test]
210    fn add_text_inverted_orientation() {
211        let label = LabelBuilder::new()
212            .add_text("Text to print.", 10, 10, '1', 15, Orientation::Inverted)
213            .unwrap()
214            .build();
215        assert_eq!(
216            label,
217            "^XA\n^LH0,0\n^A1,15^FWI^FO10,10^FDText to print.^FS\n^XZ"
218        );
219    }
220
221    #[test]
222    fn add_text_rotated_orientation() {
223        let label = LabelBuilder::new()
224            .add_text("Hello", 0, 0, 'A', 10, Orientation::Rotated)
225            .unwrap()
226            .build();
227        assert_eq!(label, "^XA\n^LH0,0\n^AA,10^FWR^FO0,0^FDHello^FS\n^XZ");
228    }
229
230    #[test]
231    fn add_text_bottom_orientation() {
232        let label = LabelBuilder::new()
233            .add_text("Hello", 0, 0, 'A', 10, Orientation::Bottom)
234            .unwrap()
235            .build();
236        assert_eq!(label, "^XA\n^LH0,0\n^AA,10^FWB^FO0,0^FDHello^FS\n^XZ");
237    }
238
239    #[test]
240    fn add_text_invalid_font() {
241        assert_eq!(
242            LabelBuilder::new().add_text("x", 10, 10, '0', 15, Orientation::Normal),
243            Err(ZplError::InvalidFont('0'))
244        );
245        assert_eq!(
246            LabelBuilder::new().add_text("x", 10, 10, 'a', 15, Orientation::Normal),
247            Err(ZplError::InvalidFont('a'))
248        );
249        assert_eq!(
250            LabelBuilder::new().add_text("x", 10, 10, ' ', 15, Orientation::Normal),
251            Err(ZplError::InvalidFont(' '))
252        );
253    }
254
255    #[test]
256    fn add_text_invalid_position() {
257        assert_eq!(
258            LabelBuilder::new().add_text("x", 32001, 0, 'A', 15, Orientation::Normal),
259            Err(ZplError::InvalidPosition(32001))
260        );
261        assert_eq!(
262            LabelBuilder::new().add_text("x", 0, 32001, 'A', 15, Orientation::Normal),
263            Err(ZplError::InvalidPosition(32001))
264        );
265    }
266
267    #[test]
268    fn add_text_invalid_font_size() {
269        assert_eq!(
270            LabelBuilder::new().add_text("x", 0, 0, 'A', 9, Orientation::Normal),
271            Err(ZplError::InvalidFontSize(9))
272        );
273    }
274
275    // ── add_barcode ───────────────────────────────────────────────────────────
276
277    #[test]
278    fn add_barcode_normal_orientation() {
279        let label = LabelBuilder::new()
280            .add_barcode(
281                BarcodeType::Code128,
282                "Text to encode.",
283                10,
284                10,
285                2,
286                3.0,
287                10,
288                Orientation::Normal,
289            )
290            .unwrap()
291            .build();
292        assert_eq!(
293            label,
294            "^XA\n^LH0,0\n^BY2,3.0,10\n^FO10,10^BC^FDText to encode.^FS\n^XZ"
295        );
296    }
297
298    #[test]
299    fn add_barcode_inverted_orientation() {
300        let label = LabelBuilder::new()
301            .add_barcode(
302                BarcodeType::Code128,
303                "Text to encode.",
304                10,
305                10,
306                2,
307                2.5,
308                10,
309                Orientation::Inverted,
310            )
311            .unwrap()
312            .build();
313        assert_eq!(
314            label,
315            "^XA\n^LH0,0\n^BY2,2.5,10\n^FO10,10^BC,I^FDText to encode.^FS\n^XZ"
316        );
317    }
318
319    #[test]
320    fn add_barcode_invalid_width() {
321        assert_eq!(
322            LabelBuilder::new().add_barcode(
323                BarcodeType::Code128,
324                "x",
325                0,
326                0,
327                0,
328                2.5,
329                10,
330                Orientation::Normal
331            ),
332            Err(ZplError::InvalidBarcodeWidth(0))
333        );
334        assert_eq!(
335            LabelBuilder::new().add_barcode(
336                BarcodeType::Code128,
337                "x",
338                0,
339                0,
340                11,
341                2.5,
342                10,
343                Orientation::Normal
344            ),
345            Err(ZplError::InvalidBarcodeWidth(11))
346        );
347    }
348
349    #[test]
350    fn add_barcode_invalid_width_ratio() {
351        assert_eq!(
352            LabelBuilder::new().add_barcode(
353                BarcodeType::Code128,
354                "x",
355                0,
356                0,
357                2,
358                1.9,
359                10,
360                Orientation::Normal
361            ),
362            Err(ZplError::InvalidWidthRatio(1.9))
363        );
364        assert_eq!(
365            LabelBuilder::new().add_barcode(
366                BarcodeType::Code128,
367                "x",
368                0,
369                0,
370                2,
371                3.1,
372                10,
373                Orientation::Normal
374            ),
375            Err(ZplError::InvalidWidthRatio(3.1))
376        );
377    }
378
379    #[test]
380    fn add_barcode_invalid_height() {
381        assert_eq!(
382            LabelBuilder::new().add_barcode(
383                BarcodeType::Code128,
384                "x",
385                0,
386                0,
387                2,
388                2.5,
389                0,
390                Orientation::Normal
391            ),
392            Err(ZplError::InvalidBarcodeHeight(0))
393        );
394    }
395
396    // ── add_graphical_box ─────────────────────────────────────────────────────
397
398    #[test]
399    fn add_graphical_box_valid() {
400        let label = LabelBuilder::new()
401            .add_graphical_box(10, 10, 100, 200, 3, Color::Black, 0)
402            .unwrap()
403            .build();
404        assert_eq!(label, "^XA\n^LH0,0\n^FO10,10^GB100,200,3,B,0^FS\n^XZ");
405    }
406
407    #[test]
408    fn add_graphical_box_white() {
409        let label = LabelBuilder::new()
410            .add_graphical_box(0, 0, 50, 50, 1, Color::White, 0)
411            .unwrap()
412            .build();
413        assert_eq!(label, "^XA\n^LH0,0\n^FO0,0^GB50,50,1,W,0^FS\n^XZ");
414    }
415
416    #[test]
417    fn add_graphical_box_width_less_than_thickness() {
418        assert_eq!(
419            LabelBuilder::new().add_graphical_box(0, 0, 2, 100, 5, Color::Black, 0),
420            Err(ZplError::InvalidBoxDimension {
421                value: 2,
422                thickness: 5
423            })
424        );
425    }
426
427    #[test]
428    fn add_graphical_box_height_less_than_thickness() {
429        assert_eq!(
430            LabelBuilder::new().add_graphical_box(0, 0, 100, 2, 5, Color::Black, 0),
431            Err(ZplError::InvalidBoxDimension {
432                value: 2,
433                thickness: 5
434            })
435        );
436    }
437
438    #[test]
439    fn add_graphical_box_invalid_rounding() {
440        assert_eq!(
441            LabelBuilder::new().add_graphical_box(0, 0, 100, 100, 1, Color::Black, 9),
442            Err(ZplError::InvalidRounding(9))
443        );
444    }
445
446    #[test]
447    fn add_graphical_box_invalid_thickness() {
448        assert_eq!(
449            LabelBuilder::new().add_graphical_box(0, 0, 100, 100, 0, Color::Black, 0),
450            Err(ZplError::InvalidThickness(0))
451        );
452    }
453
454    // ── add_graphical_circle ──────────────────────────────────────────────────
455
456    #[test]
457    fn add_graphical_circle_valid() {
458        let label = LabelBuilder::new()
459            .add_graphical_circle(10, 10, 100, 2, Color::Black)
460            .unwrap()
461            .build();
462        assert_eq!(label, "^XA\n^LH0,0\n^FO10,10^GC100,2,B^FS\n^XZ");
463    }
464
465    #[test]
466    fn add_graphical_circle_invalid_diameter() {
467        assert_eq!(
468            LabelBuilder::new().add_graphical_circle(0, 0, 2, 1, Color::Black),
469            Err(ZplError::InvalidGraphicDimension(2))
470        );
471        assert_eq!(
472            LabelBuilder::new().add_graphical_circle(0, 0, 4096, 1, Color::Black),
473            Err(ZplError::InvalidGraphicDimension(4096))
474        );
475    }
476
477    #[test]
478    fn add_graphical_circle_invalid_thickness() {
479        assert_eq!(
480            LabelBuilder::new().add_graphical_circle(0, 0, 100, 0, Color::Black),
481            Err(ZplError::InvalidThickness(0))
482        );
483    }
484
485    // ── add_graphical_ellipse ─────────────────────────────────────────────────
486
487    #[test]
488    fn add_graphical_ellipse_valid() {
489        let label = LabelBuilder::new()
490            .add_graphical_ellipse(10, 10, 100, 150, 2, Color::Black)
491            .unwrap()
492            .build();
493        assert_eq!(label, "^XA\n^LH0,0\n^FO10,10^GE100,150,2,B^FS\n^XZ");
494    }
495
496    #[test]
497    fn add_graphical_ellipse_invalid_width() {
498        assert_eq!(
499            LabelBuilder::new().add_graphical_ellipse(0, 0, 2, 100, 1, Color::Black),
500            Err(ZplError::InvalidGraphicDimension(2))
501        );
502    }
503
504    #[test]
505    fn add_graphical_ellipse_invalid_height() {
506        assert_eq!(
507            LabelBuilder::new().add_graphical_ellipse(0, 0, 100, 4096, 1, Color::Black),
508            Err(ZplError::InvalidGraphicDimension(4096))
509        );
510    }
511
512    // ── add_graphical_diagonal_line ───────────────────────────────────────────────
513
514    #[test]
515    fn add_graphical_diagonal_line_right_leaning() {
516        let label = LabelBuilder::new()
517            .add_graphical_diagonal_line(
518                10,
519                10,
520                100,
521                50,
522                2,
523                Color::Black,
524                DiagonalOrientation::RightLeaning,
525            )
526            .unwrap()
527            .build();
528        assert_eq!(label, "^XA\n^LH0,0\n^FO10,10^GD100,50,2,B,R^FS\n^XZ");
529    }
530
531    #[test]
532    fn add_graphical_diagonal_line_left_leaning() {
533        let label = LabelBuilder::new()
534            .add_graphical_diagonal_line(
535                10,
536                10,
537                100,
538                50,
539                2,
540                Color::Black,
541                DiagonalOrientation::LeftLeaning,
542            )
543            .unwrap()
544            .build();
545        assert_eq!(label, "^XA\n^LH0,0\n^FO10,10^GD100,50,2,B,L^FS\n^XZ");
546    }
547
548    #[test]
549    fn add_graphical_diagonal_line_white() {
550        let label = LabelBuilder::new()
551            .add_graphical_diagonal_line(
552                0,
553                0,
554                200,
555                100,
556                3,
557                Color::White,
558                DiagonalOrientation::RightLeaning,
559            )
560            .unwrap()
561            .build();
562        assert_eq!(label, "^XA\n^LH0,0\n^FO0,0^GD200,100,3,W,R^FS\n^XZ");
563    }
564
565    #[test]
566    fn add_graphical_diagonal_line_square_box() {
567        // box_width == box_height → 45° angle
568        let label = LabelBuilder::new()
569            .add_graphical_diagonal_line(
570                0,
571                0,
572                100,
573                100,
574                1,
575                Color::Black,
576                DiagonalOrientation::LeftLeaning,
577            )
578            .unwrap()
579            .build();
580        assert_eq!(label, "^XA\n^LH0,0\n^FO0,0^GD100,100,1,B,L^FS\n^XZ");
581    }
582
583    #[test]
584    fn add_graphical_diagonal_line_width_equal_to_thickness() {
585        // minimum valid width: exactly equal to thickness
586        let label = LabelBuilder::new()
587            .add_graphical_diagonal_line(
588                0,
589                0,
590                5,
591                50,
592                5,
593                Color::Black,
594                DiagonalOrientation::RightLeaning,
595            )
596            .unwrap()
597            .build();
598        assert_eq!(label, "^XA\n^LH0,0\n^FO0,0^GD5,50,5,B,R^FS\n^XZ");
599    }
600
601    #[test]
602    fn add_graphical_diagonal_line_invalid_thickness() {
603        assert_eq!(
604            LabelBuilder::new().add_graphical_diagonal_line(
605                0,
606                0,
607                100,
608                100,
609                0,
610                Color::Black,
611                DiagonalOrientation::RightLeaning
612            ),
613            Err(ZplError::InvalidThickness(0))
614        );
615    }
616
617    #[test]
618    fn add_graphical_diagonal_line_width_less_than_thickness() {
619        assert_eq!(
620            LabelBuilder::new().add_graphical_diagonal_line(
621                0,
622                0,
623                4,
624                100,
625                5,
626                Color::Black,
627                DiagonalOrientation::RightLeaning
628            ),
629            Err(ZplError::InvalidBoxDimension {
630                value: 4,
631                thickness: 5
632            })
633        );
634    }
635
636    #[test]
637    fn add_graphical_diagonal_line_height_less_than_thickness() {
638        assert_eq!(
639            LabelBuilder::new().add_graphical_diagonal_line(
640                0,
641                0,
642                100,
643                4,
644                5,
645                Color::Black,
646                DiagonalOrientation::LeftLeaning
647            ),
648            Err(ZplError::InvalidBoxDimension {
649                value: 4,
650                thickness: 5
651            })
652        );
653    }
654
655    #[test]
656    fn add_graphical_diagonal_line_invalid_position() {
657        assert_eq!(
658            LabelBuilder::new().add_graphical_diagonal_line(
659                32001,
660                0,
661                100,
662                100,
663                1,
664                Color::Black,
665                DiagonalOrientation::RightLeaning
666            ),
667            Err(ZplError::InvalidPosition(32001))
668        );
669    }
670
671    // ── set_home_position ─────────────────────────────────────────────────────
672
673    #[test]
674    fn set_home_position_valid() {
675        let label = LabelBuilder::new()
676            .set_home_position(50, 100)
677            .unwrap()
678            .build();
679        assert_eq!(label, "^XA\n^LH50,100\n^XZ");
680    }
681
682    #[test]
683    fn set_home_position_invalid() {
684        assert_eq!(
685            LabelBuilder::new().set_home_position(32001, 0),
686            Err(ZplError::InvalidPosition(32001))
687        );
688        assert_eq!(
689            LabelBuilder::new().set_home_position(0, 32001),
690            Err(ZplError::InvalidPosition(32001))
691        );
692    }
693
694    // ── Full label ────────────────────────────────────────────────────────────
695
696    #[test]
697    fn create_full_label() {
698        let label = LabelBuilder::new()
699            .set_home_position(10, 10)
700            .unwrap()
701            .add_text("Test label", 200, 50, '2', 50, Orientation::Normal)
702            .unwrap()
703            .add_graphical_box(50, 200, 500, 300, 3, Color::Black, 5)
704            .unwrap()
705            .add_barcode(
706                BarcodeType::Code128,
707                "Test barcode",
708                130,
709                270,
710                2,
711                2.5,
712                150,
713                Orientation::Normal,
714            )
715            .unwrap()
716            .add_graphical_ellipse(200, 700, 250, 150, 3, Color::Black)
717            .unwrap()
718            .add_graphical_circle(300, 750, 50, 3, Color::Black)
719            .unwrap()
720            .build();
721        let expected = concat!(
722            "^XA\n",
723            "^LH10,10\n",
724            "^A2,50^FO200,50^FDTest label^FS\n",
725            "^FO50,200^GB500,300,3,B,5^FS\n",
726            "^BY2,2.5,150\n^FO130,270^BC^FDTest barcode^FS\n",
727            "^FO200,700^GE250,150,3,B^FS\n",
728            "^FO300,750^GC50,3,B^FS\n",
729            "^XZ",
730        );
731        assert_eq!(label, expected);
732    }
733
734    #[test]
735    fn empty_label() {
736        let label = LabelBuilder::new().build();
737        assert_eq!(label, "^XA\n^LH0,0\n^XZ");
738    }
739
740    #[test]
741    fn error_stops_chain_early() {
742        // An invalid barcode width should prevent the label from being built,
743        // even if later elements had been valid.
744        let result = LabelBuilder::new()
745            .add_barcode(
746                BarcodeType::Code128,
747                "x",
748                0,
749                0,
750                11,
751                2.5,
752                10,
753                Orientation::Normal,
754            )
755            .and_then(|b| b.add_text("y", 0, 0, 'A', 10, Orientation::Normal));
756        assert_eq!(result, Err(ZplError::InvalidBarcodeWidth(11)));
757    }
758}