zpl-builder 0.1.0

A builder for ZPL II label strings, for use with Zebra thermal printers
Documentation
use std::fmt::Display;
use thiserror::Error;

/// Errors that can occur when building a ZPL label.
///
/// Every method on the builder validates its arguments before adding an
/// element to the label. When a value falls outside the range accepted by the
/// ZPL specification, the method returns the corresponding variant of this
/// error instead of silently producing an invalid command string.
///
/// # Example
///
/// ```rust
/// use zpl_builder::{LabelBuilder, Orientation, ZplError};
///
/// let result = LabelBuilder::new()
///     .add_text("Hello", 0, 0, '0', 15, Orientation::Normal); // '0' is not a valid font
///
/// assert_eq!(result, Err(ZplError::InvalidFont('0')));
/// ```
#[derive(Error, Debug, PartialEq, Copy, Clone)]
pub enum ZplError {
    /// A position value (x or y axis) is outside the range `0–32000`.
    ///
    /// ZPL positions are expressed in dots. The maximum addressable coordinate
    /// on either axis is 32000 dots.
    ///
    /// The inner value is the rejected position.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{LabelBuilder, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().set_home_position(32001, 0),
    ///     Err(ZplError::InvalidPosition(32001))
    /// );
    /// ```
    #[error("Position {0} out of range (0-32000)")]
    InvalidPosition(u16),

    /// A font size is outside the range `10–32000`.
    ///
    /// Font sizes are expressed in dots. Values below 10 are not accepted by
    /// the ZPL specification.
    ///
    /// The inner value is the rejected font size.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{LabelBuilder, Orientation, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_text("x", 0, 0, 'A', 9, Orientation::Normal),
    ///     Err(ZplError::InvalidFontSize(9))
    /// );
    /// ```
    #[error("Font size {0} out of range (10-32000)")]
    InvalidFontSize(u16),

    /// A barcode bar width is outside the range `1–10`.
    ///
    /// The bar width controls the width of the narrowest bar in the barcode,
    /// expressed in dots.
    ///
    /// The inner value is the rejected width.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{BarcodeType, LabelBuilder, Orientation, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_barcode(BarcodeType::Code128, "x", 0, 0, 11, 2.5, 10, Orientation::Normal),
    ///     Err(ZplError::InvalidBarcodeWidth(11))
    /// );
    /// ```
    #[error("Barcode width {0} out of range (1-10)")]
    InvalidBarcodeWidth(u8),

    /// A barcode wide-to-narrow bar width ratio is outside the range `2.0–3.0`.
    ///
    /// The ratio must be a multiple of `0.1`. It has no effect on fixed-ratio
    /// barcode types.
    ///
    /// The inner value is the rejected ratio as originally supplied by the caller.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{BarcodeType, LabelBuilder, Orientation, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_barcode(BarcodeType::Code39, "x", 0, 0, 2, 1.5, 10, Orientation::Normal),
    ///     Err(ZplError::InvalidWidthRatio(1.5))
    /// );
    /// ```
    #[error("Width ratio {0} out of range (2.0-3.0)")]
    InvalidWidthRatio(f32),

    /// A barcode height is outside the range `1–32000`.
    ///
    /// The height is expressed in dots.
    ///
    /// The inner value is the rejected height.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{BarcodeType, LabelBuilder, Orientation, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_barcode(BarcodeType::Code128, "x", 0, 0, 2, 2.5, 0, Orientation::Normal),
    ///     Err(ZplError::InvalidBarcodeHeight(0))
    /// );
    /// ```
    #[error("Barcode height {0} out of range (1-32000)")]
    InvalidBarcodeHeight(u16),

    /// A line thickness is outside the range `1–32000`.
    ///
    /// The thickness is expressed in dots and applies to boxes, circles and
    /// ellipses.
    ///
    /// The inner value is the rejected thickness.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{Color, LabelBuilder, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_graphical_box(0, 0, 100, 100, 0, Color::Black, 0),
    ///     Err(ZplError::InvalidThickness(0))
    /// );
    /// ```
    #[error("Thickness {0} out of range (1-32000)")]
    InvalidThickness(u16),

    /// A graphical dimension (diameter, width or height of a circle or ellipse)
    /// is outside the range `3–4095`.
    ///
    /// The dimension is expressed in dots.
    ///
    /// The inner value is the rejected dimension.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{Color, LabelBuilder, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_graphical_circle(0, 0, 2, 1, Color::Black),
    ///     Err(ZplError::InvalidGraphicDimension(2))
    /// );
    /// ```
    #[error("Graphic dimension {0} out of range (3-4095)")]
    InvalidGraphicDimension(u16),

    /// A corner rounding value is outside the range `0–8`.
    ///
    /// `0` produces sharp corners. `8` produces the most rounded corners.
    ///
    /// The inner value is the rejected rounding.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{Color, LabelBuilder, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_graphical_box(0, 0, 100, 100, 1, Color::Black, 9),
    ///     Err(ZplError::InvalidRounding(9))
    /// );
    /// ```
    #[error("Rounding {0} out of range (0-8)")]
    InvalidRounding(u8),

    /// A font identifier is not a valid ZPL font name.
    ///
    /// Valid font names are a single ASCII uppercase letter (`A`–`Z`) or a
    /// digit from `1` to `9`. The digit `0` (zero) is not a valid font name.
    ///
    /// The inner value is the rejected character.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{LabelBuilder, Orientation, ZplError};
    ///
    /// assert_eq!(
    ///     LabelBuilder::new().add_text("x", 0, 0, '0', 10, Orientation::Normal),
    ///     Err(ZplError::InvalidFont('0'))
    /// );
    /// ```
    #[error("Invalid font '{0}' (expected A-Z or 1-9)")]
    InvalidFont(char),

    /// A box width or height is smaller than the box thickness.
    ///
    /// ZPL requires that the width and height of a graphical box each be at
    /// least equal to its line thickness, otherwise the box cannot be rendered.
    ///
    /// # Fields
    ///
    /// * `value` — the rejected width or height, in dots.
    /// * `thickness` — the thickness the dimension was compared against, in dots.
    ///
    /// # Example
    ///
    /// ```rust
    /// use zpl_builder::{Color, LabelBuilder, ZplError};
    ///
    /// // thickness = 5, width = 2 → width < thickness
    /// assert_eq!(
    ///     LabelBuilder::new().add_graphical_box(0, 0, 2, 100, 5, Color::Black, 0),
    ///     Err(ZplError::InvalidBoxDimension { value: 2, thickness: 5 })
    /// );
    /// ```
    #[error(
        "Box dimension {value} is smaller than thickness {thickness} (must be {thickness}-32000)"
    )]
    InvalidBoxDimension {
        /// The rejected width or height, in dots.
        value: u16,
        /// The line thickness the dimension was compared against, in dots.
        thickness: u16,
    },
}

// ---- Macro to construct types ----
macro_rules! bounded_u16 {
    ($name: ident, $min:expr, $max:expr, $err:ident) => {
        #[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
        pub(crate) struct $name(pub(crate) u16);

        impl TryFrom<u16> for $name {
            type Error = ZplError;
            fn try_from(v: u16) -> Result<Self, Self::Error> {
                if ($min..=$max).contains(&v) {
                    Ok(Self(v))
                } else {
                    Err(ZplError::$err(v))
                }
            }
        }

        impl Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }
    };
}

macro_rules! bounded_u8 {
    ($name: ident, $min:expr, $max:expr, $err:ident) => {
        #[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
        pub(crate) struct $name(pub(crate) u8);

        impl TryFrom<u8> for $name {
            type Error = ZplError;
            fn try_from(v: u8) -> Result<Self, Self::Error> {
                if ($min..=$max).contains(&v) {
                    Ok(Self(v))
                } else {
                    Err(ZplError::$err(v))
                }
            }
        }

        impl Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }
    };
}

// ---- Types ----

bounded_u16!(AxisPosition, 0, 32000, InvalidPosition);
bounded_u16!(FontSize, 10, 32000, InvalidFontSize);
bounded_u16!(BarcodeHeight, 1, 32000, InvalidBarcodeHeight);
bounded_u16!(Thickness, 1, 32000, InvalidThickness);
bounded_u16!(GraphicDimension, 3, 4095, InvalidGraphicDimension);

bounded_u8!(BarcodeWidth, 1, 10, InvalidBarcodeWidth);
bounded_u8!(Rounding, 0, 8, InvalidRounding);

#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
pub(crate) struct WidthRatio(pub(crate) u8);

impl TryFrom<f32> for WidthRatio {
    type Error = ZplError;

    fn try_from(value: f32) -> Result<Self, Self::Error> {
        let tenths = (value * 10.0).round() as i32;
        if (20..=30).contains(&tenths) {
            Ok(Self(tenths as u8))
        } else {
            Err(ZplError::InvalidWidthRatio(value))
        }
    }
}

impl Display for WidthRatio {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.1}", self.0 as f32 / 10.0)
    }
}

#[derive(Debug, PartialEq, Clone)]
pub(crate) struct Font(pub(crate) char);

impl TryFrom<char> for Font {
    type Error = ZplError;

    fn try_from(c: char) -> Result<Self, Self::Error> {
        if c.is_ascii_uppercase() || ('1'..='9').contains(&c) {
            Ok(Self(c))
        } else {
            Err(ZplError::InvalidFont(c))
        }
    }
}

impl Display for Font {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub(crate) struct BoxDimension(pub(crate) u16);

impl BoxDimension {
    pub(crate) fn try_new(value: u16, thickness: Thickness) -> Result<Self, ZplError> {
        if (thickness.0..=32000).contains(&value) {
            Ok(Self(value))
        } else {
            Err(ZplError::InvalidBoxDimension {
                value,
                thickness: thickness.0,
            })
        }
    }
}

impl Display for BoxDimension {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}