aboutsummaryrefslogtreecommitdiff
path: root/src/core/print/renderer.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/print/renderer.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/core/print/renderer.rs')
-rw-r--r--src/core/print/renderer.rs1537
1 files changed, 1537 insertions, 0 deletions
diff --git a/src/core/print/renderer.rs b/src/core/print/renderer.rs
new file mode 100644
index 0000000..79a8702
--- /dev/null
+++ b/src/core/print/renderer.rs
@@ -0,0 +1,1537 @@
+use anyhow::{bail, Context, Result};
+use base64::Engine;
+use eframe::egui;
+use printpdf::*;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+use crate::core::print::parsing::{PrinterMargins, PrinterSettings, ScaleMode};
+
+// -----------------------------------------------------------------------------
+// Constants
+// -----------------------------------------------------------------------------
+const POINTS_TO_MM: f32 = 0.352_777_78; // 1 pt -> mm
+const TEXT_DESCENT_RATIO: f32 = 0.2;
+
+// Fallback page if no printer settings provided
+const DEFAULT_CANVAS_WIDTH_MM: f32 = 100.0;
+const DEFAULT_CANVAS_HEIGHT_MM: f32 = 50.0;
+
+// -----------------------------------------------------------------------------
+// Grid / Space definition for new layout system
+// -----------------------------------------------------------------------------
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct LayoutSpace {
+ pub width: f32,
+ pub height: f32,
+}
+
+impl Default for LayoutSpace {
+ fn default() -> Self {
+ Self {
+ width: 256.0,
+ height: 128.0,
+ }
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Core layout structs (grid-based)
+// -----------------------------------------------------------------------------
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LabelLayout {
+ #[serde(default)]
+ pub background: Option<String>,
+ #[serde(default)]
+ pub space: LayoutSpace,
+ #[serde(default)]
+ pub elements: Vec<LabelElement>,
+}
+
+fn default_font_size() -> f32 {
+ 12.0
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum LabelElement {
+ Text {
+ field: String,
+ x: f32,
+ y: f32,
+ #[serde(rename = "fontSize", default = "default_font_size")]
+ font_size: f32,
+ #[serde(rename = "fontWeight", default)]
+ font_weight: Option<String>,
+ #[serde(rename = "fontFamily", default)]
+ font_family: Option<String>,
+ #[serde(rename = "maxWidth", default)]
+ max_width: Option<f32>,
+ #[serde(default)]
+ wrap: Option<bool>,
+ #[serde(default)]
+ color: Option<String>,
+ },
+ QrCode {
+ field: String,
+ x: f32,
+ y: f32,
+ size: f32,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ Barcode {
+ field: String,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ #[serde(default)]
+ format: Option<String>,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ DataMatrix {
+ field: String,
+ x: f32,
+ y: f32,
+ size: f32,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ Rect {
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ #[serde(default)]
+ fill: Option<String>,
+ },
+ Svg {
+ data: String,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ },
+}
+
+#[derive(Debug, Clone)]
+pub struct LabelRenderer {
+ pub layout: LabelLayout,
+}
+
+impl LabelRenderer {
+ pub fn new(layout: LabelLayout) -> Self {
+ Self { layout }
+ }
+}
+
+// Bounds actually used by elements (tight box)
+#[derive(Debug, Clone, Copy)]
+struct LayoutBounds {
+ min_x: f32,
+ min_y: f32,
+ max_x: f32,
+ max_y: f32,
+}
+
+impl LayoutBounds {
+ fn empty() -> Self {
+ Self {
+ min_x: f32::INFINITY,
+ min_y: f32::INFINITY,
+ max_x: f32::NEG_INFINITY,
+ max_y: f32::NEG_INFINITY,
+ }
+ }
+ fn is_empty(&self) -> bool {
+ !self.min_x.is_finite()
+ }
+ fn extend_point(&mut self, x: f32, y: f32) {
+ self.min_x = self.min_x.min(x);
+ self.min_y = self.min_y.min(y);
+ self.max_x = self.max_x.max(x);
+ self.max_y = self.max_y.max(y);
+ }
+ fn extend_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+ let (min_x, max_x) = if w >= 0.0 { (x, x + w) } else { (x + w, x) };
+ let (min_y, max_y) = if h >= 0.0 { (y, y + h) } else { (y + h, y) };
+ self.extend_point(min_x, min_y);
+ self.extend_point(max_x, max_y);
+ }
+ fn width(&self) -> f32 {
+ (self.max_x - self.min_x).max(0.0)
+ }
+ fn height(&self) -> f32 {
+ (self.max_y - self.min_y).max(0.0)
+ }
+ fn normalize_x(&self, x: f32) -> f32 {
+ if self.min_x.is_finite() {
+ x - self.min_x
+ } else {
+ x
+ }
+ }
+ fn normalize_y(&self, y: f32) -> f32 {
+ if self.min_y.is_finite() {
+ y - self.min_y
+ } else {
+ y
+ }
+ }
+}
+
+// LayoutTransform maps layout-space units onto final mm coordinates
+#[derive(Debug, Clone)]
+#[allow(dead_code)]
+struct LayoutTransform {
+ bounds: LayoutBounds,
+ page_width: f32,
+ page_height: f32,
+ printable_width: f32,
+ printable_height: f32,
+ scale_x: f32,
+ scale_y: f32,
+ uniform_scale: f32,
+ offset_x: f32,
+ offset_y: f32,
+ rendered_width: f32,
+ rendered_height: f32,
+ margins: PrinterMargins,
+}
+
+impl LayoutTransform {
+ fn new(bounds: LayoutBounds, settings: Option<&PrinterSettings>) -> Result<Self> {
+ let margins = settings.map(|s| s.margins.clone()).unwrap_or_default();
+ let (page_w, page_h) = if let Some(s) = settings {
+ // Respect printer-provided orientation (already canonicalized by get_dimensions_mm)
+ s.get_dimensions_mm()
+ } else {
+ // No settings: default preview page matches design aspect
+ (
+ bounds.width().max(DEFAULT_CANVAS_WIDTH_MM),
+ bounds.height().max(DEFAULT_CANVAS_HEIGHT_MM),
+ )
+ };
+ let printable_w = (page_w - margins.left - margins.right).max(1.0);
+ let printable_h = (page_h - margins.top - margins.bottom).max(1.0);
+ let design_w = bounds.width().max(1.0);
+ let design_h = bounds.height().max(1.0);
+
+ let scale_mode = settings.map(|s| s.scale_mode).unwrap_or(ScaleMode::Fit);
+ let user_factor = settings.map(|s| s.scale_factor).unwrap_or(1.0).max(0.0);
+
+ let mut sx = printable_w / design_w;
+ let mut sy = printable_h / design_h;
+ match scale_mode {
+ ScaleMode::Fit => {
+ let uni = sx.min(sy);
+ sx = uni;
+ sy = uni;
+ }
+ ScaleMode::FitX => {
+ sy = sx;
+ }
+ ScaleMode::FitY => {
+ sx = sy;
+ }
+ ScaleMode::MaxBoth => { /* stretch independently */ }
+ ScaleMode::MaxX => {
+ sy = sx;
+ }
+ ScaleMode::MaxY => {
+ sx = sy;
+ }
+ ScaleMode::Manual => {
+ sx = user_factor;
+ sy = user_factor;
+ }
+ }
+ sx *= user_factor;
+ sy *= user_factor; // Manual already multiplies; harmless if 1.0
+ if !sx.is_finite() || sx <= 0.0 {
+ sx = 1.0;
+ }
+ if !sy.is_finite() || sy <= 0.0 {
+ sy = 1.0;
+ }
+ let uniform = sx.min(sy);
+ let rendered_w = design_w * sx;
+ let rendered_h = design_h * sy;
+ // Centering
+ let mut offset_x = margins.left;
+ let mut offset_y = margins.top;
+ if let Some(s) = settings {
+ if let Some(center_mode) = s.center.filter(|_| !s.center_disabled) {
+ if center_mode.includes_horizontal() {
+ let extra = printable_w - rendered_w;
+ if extra > 0.0 {
+ offset_x = margins.left + extra / 2.0;
+ }
+ }
+ if center_mode.includes_vertical() {
+ let extra = printable_h - rendered_h;
+ if extra > 0.0 {
+ offset_y = margins.top + extra / 2.0;
+ }
+ }
+ }
+ }
+ log::info!("layout_transform: page {:.2}x{:.2}mm printable {:.2}x{:.2}mm design {:.2}x{:.2} units scale_x {:.4} scale_y {:.4} uniform {:.4} offsets {:.2},{:.2}",
+ page_w, page_h, printable_w, printable_h, design_w, design_h, sx, sy, uniform, offset_x, offset_y);
+ Ok(Self {
+ bounds,
+ page_width: page_w,
+ page_height: page_h,
+ printable_width: printable_w,
+ printable_height: printable_h,
+ scale_x: sx,
+ scale_y: sy,
+ uniform_scale: uniform,
+ offset_x,
+ offset_y,
+ rendered_width: rendered_w,
+ rendered_height: rendered_h,
+ margins,
+ })
+ }
+ fn x_mm(&self, x: f32) -> f32 {
+ self.offset_x + self.scale_x * self.bounds.normalize_x(x)
+ }
+ fn y_mm(&self, y: f32) -> f32 {
+ self.offset_y + self.scale_y * self.bounds.normalize_y(y)
+ }
+ fn width_mm(&self, w: f32) -> f32 {
+ self.scale_x * w
+ }
+ fn height_mm(&self, h: f32) -> f32 {
+ self.scale_y * h
+ }
+ fn uniform_mm(&self, s: f32) -> f32 {
+ self.uniform_scale * s
+ }
+}
+
+impl LabelRenderer {
+ fn render_pdf_internal(
+ &self,
+ data: &HashMap<String, String>,
+ printer_settings: Option<&PrinterSettings>,
+ ) -> Result<(
+ PdfDocumentReference,
+ PdfPageIndex,
+ PdfLayerIndex,
+ LayoutTransform,
+ )> {
+ let bounds = self.calculate_layout_bounds()?;
+ let transform = LayoutTransform::new(bounds, printer_settings)?;
+
+ let (doc, page_index, layer_index) = PdfDocument::new(
+ "BeepZone Label",
+ Mm(transform.page_width),
+ Mm(transform.page_height),
+ "Layer 1",
+ );
+
+ let font = doc
+ .add_builtin_font(printpdf::BuiltinFont::Helvetica)
+ .context("Failed to add Helvetica font")?;
+
+ let layer = doc.get_page(page_index).get_layer(layer_index);
+ self.render_pdf_elements(&layer, &font, data, &transform)?;
+
+ Ok((doc, page_index, layer_index, transform))
+ }
+
+ fn render_pdf_elements(
+ &self,
+ layer: &PdfLayerReference,
+ font: &IndirectFontRef,
+ data: &HashMap<String, String>,
+ transform: &LayoutTransform,
+ ) -> Result<()> {
+ for element in &self.layout.elements {
+ match element {
+ LabelElement::Text {
+ field,
+ x,
+ y,
+ font_size,
+ color,
+ max_width,
+ wrap,
+ ..
+ } => {
+ let value = Self::resolve_field(field, data);
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let font_pt = (*font_size * transform.uniform_scale).max(0.1);
+ let color_ref = color.as_deref();
+
+ let wrap_enabled = wrap.unwrap_or(false);
+ if wrap_enabled {
+ if let Some(max_w) = max_width {
+ let max_w_mm = transform.width_mm(*max_w);
+ let lines = Self::wrap_lines(&value, max_w_mm, font_pt);
+ let line_gap_mm = font_pt * POINTS_TO_MM * 1.2;
+ for (i, line) in lines.iter().enumerate() {
+ let line_top_mm = y_mm + (i as f32) * line_gap_mm;
+ let baseline = Self::layout_text_baseline(
+ line_top_mm,
+ font_pt,
+ transform.page_height,
+ );
+ self.render_text_to_pdf(
+ layer, font, x_mm, baseline, font_pt, line, color_ref,
+ )?;
+ }
+ } else {
+ let baseline =
+ Self::layout_text_baseline(y_mm, font_pt, transform.page_height);
+ self.render_text_to_pdf(
+ layer, font, x_mm, baseline, font_pt, &value, color_ref,
+ )?;
+ }
+ } else {
+ let baseline =
+ Self::layout_text_baseline(y_mm, font_pt, transform.page_height);
+ self.render_text_to_pdf(
+ layer, font, x_mm, baseline, font_pt, &value, color_ref,
+ )?;
+ }
+ }
+ LabelElement::QrCode {
+ field,
+ x,
+ y,
+ size,
+ show_text,
+ ..
+ } => {
+ let value = Self::resolve_field(field, data);
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let side_mm = transform.uniform_mm(*size);
+ let y_bottom =
+ Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height);
+ self.render_qrcode_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?;
+
+ if show_text.unwrap_or(false) {
+ let gap_mm = 1.5 * transform.uniform_scale;
+ let label_top_mm = y_mm + side_mm + gap_mm;
+ let label_font_pt = 10.0 * transform.uniform_scale;
+ let baseline = Self::layout_text_baseline(
+ label_top_mm,
+ label_font_pt,
+ transform.page_height,
+ );
+ self.render_text_to_pdf(
+ layer,
+ font,
+ x_mm,
+ baseline,
+ label_font_pt,
+ &value,
+ None,
+ )?;
+ }
+ }
+ LabelElement::DataMatrix {
+ field,
+ x,
+ y,
+ size,
+ show_text,
+ } => {
+ let value = Self::resolve_field(field, data);
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let side_mm = transform.uniform_mm(*size);
+ let y_bottom =
+ Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height);
+ self.render_datamatrix_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?;
+
+ if show_text.unwrap_or(false) {
+ let gap_mm = 1.5 * transform.uniform_scale;
+ let label_top_mm = y_mm + side_mm + gap_mm;
+ let label_font_pt = 8.0 * transform.uniform_scale;
+ let baseline = Self::layout_text_baseline(
+ label_top_mm,
+ label_font_pt,
+ transform.page_height,
+ );
+ self.render_text_to_pdf(
+ layer,
+ font,
+ x_mm,
+ baseline,
+ label_font_pt,
+ &value,
+ None,
+ )?;
+ }
+ }
+ LabelElement::Barcode {
+ field,
+ x,
+ y,
+ width,
+ height,
+ format,
+ show_text,
+ } => {
+ let value = Self::resolve_field(field, data);
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let width_mm = transform.width_mm(*width);
+ let height_mm = transform.height_mm(*height);
+ let y_bottom =
+ Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height);
+
+ self.render_barcode_to_pdf(
+ layer,
+ x_mm,
+ y_bottom,
+ width_mm,
+ height_mm,
+ &value,
+ format.as_deref(),
+ )?;
+
+ if show_text.unwrap_or(false) {
+ let gap_mm = 1.5 * transform.uniform_scale;
+ let label_top_mm = y_mm + height_mm + gap_mm;
+ let label_font_pt = 8.0 * transform.uniform_scale;
+ let baseline = Self::layout_text_baseline(
+ label_top_mm,
+ label_font_pt,
+ transform.page_height,
+ );
+ self.render_text_to_pdf(
+ layer,
+ font,
+ x_mm,
+ baseline,
+ label_font_pt,
+ &value,
+ None,
+ )?;
+ }
+ }
+ LabelElement::Rect {
+ x,
+ y,
+ width,
+ height,
+ fill,
+ } => {
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let width_mm = transform.width_mm(*width);
+ let height_mm = transform.height_mm(*height);
+ let y_bottom =
+ Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height);
+ self.render_rect_to_pdf(
+ layer,
+ x_mm,
+ y_bottom,
+ width_mm,
+ height_mm,
+ fill.as_deref(),
+ )?;
+ }
+ LabelElement::Svg {
+ x,
+ y,
+ width,
+ height,
+ data,
+ } => {
+ let x_mm = transform.x_mm(*x);
+ let y_mm = transform.y_mm(*y);
+ let width_mm = transform.width_mm(*width).max(0.1);
+ let height_mm = transform.height_mm(*height).max(0.1);
+ let y_bottom =
+ Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height);
+
+ if let Some(svg_xml) = Self::decode_svg_data_uri(data) {
+ let px_w = (width_mm * 3.78).ceil().max(1.0) as u32;
+ let px_h = (height_mm * 3.78).ceil().max(1.0) as u32;
+ if let Some(rgba) = Self::rasterize_svg_to_rgba(&svg_xml, px_w, px_h) {
+ let mut rgb: Vec<u8> = Vec::with_capacity((px_w * px_h * 3) as usize);
+ for chunk in rgba.chunks(4) {
+ let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
+ if a == 255 {
+ rgb.extend_from_slice(&[r, g, b]);
+ } else {
+ let af = a as f32 / 255.0;
+ let blend = |c: u8| {
+ ((c as f32 * af) + 255.0 * (1.0 - af)).round() as u8
+ };
+ rgb.extend_from_slice(&[blend(r), blend(g), blend(b)]);
+ }
+ }
+
+ let image_xobj = printpdf::ImageXObject {
+ width: printpdf::Px(px_w as usize),
+ height: printpdf::Px(px_h as usize),
+ color_space: printpdf::ColorSpace::Rgb,
+ bits_per_component: printpdf::ColorBits::Bit8,
+ interpolate: true,
+ image_data: rgb,
+ image_filter: None,
+ clipping_bbox: None,
+ smask: None,
+ };
+
+ let image = printpdf::Image::from(image_xobj);
+ let base_w_mm = (px_w as f32) * 25.4 / 300.0;
+ let base_h_mm = (px_h as f32) * 25.4 / 300.0;
+ let sx = if base_w_mm > 0.0 {
+ width_mm / base_w_mm
+ } else {
+ 1.0
+ };
+ let sy = if base_h_mm > 0.0 {
+ height_mm / base_h_mm
+ } else {
+ 1.0
+ };
+ let transform_img = printpdf::ImageTransform {
+ translate_x: Some(printpdf::Mm(x_mm)),
+ translate_y: Some(printpdf::Mm(y_bottom)),
+ rotate: None,
+ scale_x: Some(sx),
+ scale_y: Some(sy),
+ dpi: Some(300.0),
+ };
+ image.add_to_layer(layer.clone(), transform_img);
+ } else {
+ self.render_rect_to_pdf(
+ layer,
+ x_mm,
+ y_bottom,
+ width_mm,
+ height_mm,
+ Some("#DDDDDD"),
+ )?;
+ }
+ } else {
+ self.render_rect_to_pdf(
+ layer,
+ x_mm,
+ y_bottom,
+ width_mm,
+ height_mm,
+ Some("#EEEEEE"),
+ )?;
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn resolve_field(field: &str, data: &HashMap<String, String>) -> String {
+ if !field.contains("{{") {
+ return data
+ .get(field)
+ .cloned()
+ .unwrap_or_else(|| field.to_string());
+ }
+
+ let mut result = String::new();
+ let mut rest = field;
+
+ while let Some(open) = rest.find("{{") {
+ let (prefix, tail) = rest.split_at(open);
+ result.push_str(prefix);
+
+ if let Some(close) = tail.find("}}") {
+ let var = tail[2..close].trim();
+ // Exact match first, then case-insensitive fallback
+ if let Some(value) = data.get(var) {
+ result.push_str(value);
+ } else if let Some((_, v)) = data.iter().find(|(k, _)| k.eq_ignore_ascii_case(var))
+ {
+ result.push_str(v);
+ } // else: missing vars become empty string
+ rest = &tail[close + 2..];
+ } else {
+ result.push_str(tail);
+ return result;
+ }
+ }
+
+ result.push_str(rest);
+ result
+ }
+
+ fn calculate_layout_bounds(&self) -> Result<LayoutBounds> {
+ let space = self.layout.space;
+ if !space.width.is_finite() || !space.height.is_finite() {
+ bail!("layout space must provide finite width and height");
+ }
+ if space.width <= 0.0 || space.height <= 0.0 {
+ bail!("layout space must define positive width and height");
+ }
+
+ let mut used = LayoutBounds::empty();
+
+ for element in &self.layout.elements {
+ match element {
+ LabelElement::Text {
+ x,
+ y,
+ font_size,
+ max_width,
+ ..
+ } => {
+ let height = (*font_size * POINTS_TO_MM).max(0.1);
+ // Wider heuristic for text width so long strings trigger downscaling.
+ let width = max_width
+ .and_then(|w| {
+ if w.is_finite() && w > 0.0 {
+ Some(w)
+ } else {
+ None
+ }
+ })
+ .unwrap_or_else(|| (*font_size * POINTS_TO_MM * 8.5).max(1.0));
+ used.extend_rect(*x, *y, width, height);
+ }
+ LabelElement::QrCode { x, y, size, .. }
+ | LabelElement::DataMatrix { x, y, size, .. } => {
+ used.extend_rect(*x, *y, *size, *size);
+ }
+ LabelElement::Barcode {
+ x,
+ y,
+ width,
+ height,
+ ..
+ } => {
+ used.extend_rect(*x, *y, *width, *height);
+ }
+ LabelElement::Rect {
+ x,
+ y,
+ width,
+ height,
+ ..
+ } => {
+ used.extend_rect(*x, *y, *width, *height);
+ }
+ LabelElement::Svg {
+ x,
+ y,
+ width,
+ height,
+ ..
+ } => {
+ used.extend_rect(*x, *y, *width, *height);
+ }
+ }
+ }
+
+ if used.is_empty() {
+ return Ok(LayoutBounds {
+ min_x: 0.0,
+ min_y: 0.0,
+ max_x: space.width,
+ max_y: space.height,
+ });
+ }
+
+ let mut bounds = used;
+ bounds.min_x = bounds.min_x.max(0.0);
+ bounds.min_y = bounds.min_y.max(0.0);
+
+ let min_width = (space.width * 0.01).max(1.0);
+ let min_height = (space.height * 0.01).max(1.0);
+
+ if bounds.width() < min_width {
+ bounds.min_x = 0.0;
+ bounds.max_x = space.width;
+ } else if bounds.max_x > space.width {
+ // allow overhang but ensure width positive
+ bounds.max_x = bounds.max_x.max(bounds.min_x + min_width);
+ }
+
+ if bounds.height() < min_height {
+ bounds.min_y = 0.0;
+ bounds.max_y = space.height;
+ } else if bounds.max_y > space.height {
+ bounds.max_y = bounds.max_y.max(bounds.min_y + min_height);
+ }
+
+ // No seal() needed; bounds already finalized
+ Ok(bounds)
+ }
+
+ fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
+ let raw = hex.trim();
+ let raw = raw.strip_prefix('#').unwrap_or(raw);
+ if raw.len() != 6 {
+ return None;
+ }
+
+ let r = u8::from_str_radix(&raw[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&raw[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&raw[4..6], 16).ok()?;
+
+ Some(egui::Color32::from_rgb(r, g, b))
+ }
+
+ #[allow(dead_code)]
+ pub fn generate_pdf(
+ &self,
+ data: &HashMap<String, String>,
+ ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> {
+ let (doc, page, layer, _) = self.render_pdf_internal(data, None)?;
+ Ok((doc, page, layer))
+ }
+
+ pub fn generate_pdf_with_settings(
+ &self,
+ data: &HashMap<String, String>,
+ printer_settings: &PrinterSettings,
+ ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> {
+ let (doc, page, layer, _) = self.render_pdf_internal(data, Some(printer_settings))?;
+ Ok((doc, page, layer))
+ }
+
+ // Removed legacy template bounds calculation (numeric widths now direct)
+
+ fn render_text_to_pdf(
+ &self,
+ layer: &PdfLayerReference,
+ font: &IndirectFontRef,
+ x: f32,
+ baseline_y: f32,
+ font_size_pt: f32,
+ text: &str,
+ color: Option<&str>,
+ ) -> Result<()> {
+ let (r, g, b) = color.map(Self::parse_hex_to_rgb).unwrap_or((0.0, 0.0, 0.0));
+
+ layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None)));
+ layer.use_text(text, font_size_pt, Mm(x), Mm(baseline_y), font);
+
+ Ok(())
+ }
+
+ fn render_qrcode_to_pdf(
+ &self,
+ layer: &PdfLayerReference,
+ x: f32,
+ y_bottom: f32,
+ size: f32,
+ data: &str,
+ ) -> Result<()> {
+ use qrcodegen::{QrCode, QrCodeEcc};
+
+ let qr =
+ QrCode::encode_text(data, QrCodeEcc::Medium).context("Failed to generate QR code")?;
+
+ let qr_size = qr.size() as usize;
+ let module_mm = size / qr_size as f32;
+
+ layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(
+ 0.0, 0.0, 0.0, None,
+ )));
+
+ for y in 0..qr_size {
+ for x_idx in 0..qr_size {
+ if qr.get_module(x_idx as i32, y as i32) {
+ let px = x + (x_idx as f32 * module_mm);
+ let py = y_bottom + ((qr_size - 1 - y) as f32 * module_mm);
+ Self::draw_filled_rect(layer, px, py, module_mm, module_mm);
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn render_datamatrix_to_pdf(
+ &self,
+ layer: &PdfLayerReference,
+ x: f32,
+ y_bottom: f32,
+ size: f32,
+ data: &str,
+ ) -> Result<()> {
+ use datamatrix::{DataMatrix, SymbolList};
+ let encoded = match DataMatrix::encode_str(data, SymbolList::default()) {
+ Ok(dm) => dm,
+ Err(e) => {
+ log::error!("Failed to generate DataMatrix for '{}': {:?}", data, e);
+ return Ok(());
+ }
+ };
+ let bmp = encoded.bitmap();
+ let rows = bmp.height() as usize;
+ let cols = bmp.width() as usize;
+ if rows == 0 || cols == 0 {
+ return Ok(());
+ }
+ let module_mm = size / rows.max(cols) as f32;
+ layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(
+ 0.0, 0.0, 0.0, None,
+ )));
+ for (px_idx, py_idx) in bmp.pixels() {
+ // (x,y)
+ let px_mm = x + px_idx as f32 * module_mm;
+ let py_mm = y_bottom + ((rows - 1 - py_idx) as f32 * module_mm);
+ Self::draw_filled_rect(layer, px_mm, py_mm, module_mm, module_mm);
+ }
+ Ok(())
+ }
+
+ fn render_barcode_to_pdf(
+ &self,
+ layer: &PdfLayerReference,
+ x: f32,
+ y_bottom: f32,
+ width: f32,
+ height: f32,
+ data: &str,
+ format: Option<&str>,
+ ) -> Result<()> {
+ use barcoders::sym::{code11::Code11, code128::Code128};
+
+ // Choose symbology
+ enum Sym {
+ C128(String),
+ C11(String),
+ }
+
+ let sym = match format.map(|s| s.to_lowercase()) {
+ Some(ref f) if f == "code11" => {
+ // Code11 supports digits and '-'
+ let cleaned: String = data
+ .chars()
+ .filter(|c| c.is_ascii_digit() || *c == '-')
+ .collect();
+ if cleaned.is_empty() {
+ log::warn!("Skipping Code11 - invalid payload: '{}'", data);
+ return Ok(());
+ }
+ Sym::C11(cleaned)
+ }
+ _ => {
+ // Default Code128 with smart preparation
+ match Self::prepare_code128_payload(data) {
+ Some(p) => Sym::C128(p),
+ None => {
+ log::warn!("Skipping barcode - unsupported payload: '{}'", data);
+ return Ok(());
+ }
+ }
+ }
+ };
+
+ let modules: Vec<u8> = match sym {
+ Sym::C128(p) => match Code128::new(&p) {
+ Ok(c) => c.encode(),
+ Err(e) => {
+ log::error!("Code128 encode failed: {:?}", e);
+ return Ok(());
+ }
+ },
+ Sym::C11(p) => match Code11::new(&p) {
+ Ok(c) => c.encode(),
+ Err(e) => {
+ log::error!("Code11 encode failed: {:?}", e);
+ return Ok(());
+ }
+ },
+ };
+
+ if modules.is_empty() {
+ log::warn!("Barcode produced no modules");
+ return Ok(());
+ }
+
+ let module_width = width / modules.len() as f32;
+ if module_width <= 0.0 {
+ log::warn!("Computed non-positive module width, skipping");
+ return Ok(());
+ }
+
+ layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(
+ 0.0, 0.0, 0.0, None,
+ )));
+
+ let mut run_start: Option<usize> = None;
+ for (idx, bit) in modules.iter().enumerate() {
+ if *bit == 1 {
+ run_start.get_or_insert(idx);
+ } else if let Some(start) = run_start.take() {
+ let bar_start = x + start as f32 * module_width;
+ let bar_width = (idx - start) as f32 * module_width;
+ if bar_width > 0.0 {
+ Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height);
+ }
+ }
+ }
+
+ if let Some(start) = run_start.take() {
+ let bar_start = x + start as f32 * module_width;
+ let bar_width = (modules.len() - start) as f32 * module_width;
+ if bar_width > 0.0 {
+ Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height);
+ }
+ }
+
+ Ok(())
+ }
+
+ fn render_rect_to_pdf(
+ &self,
+ layer: &PdfLayerReference,
+ x: f32,
+ y_bottom: f32,
+ width_mm: f32,
+ height_mm: f32,
+ fill: Option<&str>,
+ ) -> Result<()> {
+ use printpdf::path::{PaintMode, WindingOrder};
+
+ let (r, g, b) = fill.map(Self::parse_hex_to_rgb).unwrap_or((0.5, 0.5, 0.5));
+
+ layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None)));
+
+ let points = vec![
+ (Point::new(Mm(x), Mm(y_bottom)), false),
+ (Point::new(Mm(x + width_mm), Mm(y_bottom)), false),
+ (
+ Point::new(Mm(x + width_mm), Mm(y_bottom + height_mm)),
+ false,
+ ),
+ (Point::new(Mm(x), Mm(y_bottom + height_mm)), false),
+ ];
+
+ let polygon = printpdf::Polygon {
+ rings: vec![points],
+ mode: PaintMode::Fill,
+ winding_order: WindingOrder::NonZero,
+ };
+
+ layer.add_polygon(polygon);
+ Ok(())
+ }
+
+ fn parse_hex_to_rgb(hex: &str) -> (f32, f32, f32) {
+ let raw = hex.trim();
+ let raw = raw.strip_prefix('#').unwrap_or(raw);
+ if raw.len() != 6 {
+ return (0.0, 0.0, 0.0);
+ }
+
+ let r = u8::from_str_radix(&raw[0..2], 16).unwrap_or(0) as f32 / 255.0;
+ let g = u8::from_str_radix(&raw[2..4], 16).unwrap_or(0) as f32 / 255.0;
+ let b = u8::from_str_radix(&raw[4..6], 16).unwrap_or(0) as f32 / 255.0;
+
+ (r, g, b)
+ }
+
+ fn draw_filled_rect(layer: &PdfLayerReference, x: f32, y_bottom: f32, width: f32, height: f32) {
+ use printpdf::path::{PaintMode, WindingOrder};
+
+ let points = vec![
+ (Point::new(Mm(x), Mm(y_bottom)), false),
+ (Point::new(Mm(x + width), Mm(y_bottom)), false),
+ (Point::new(Mm(x + width), Mm(y_bottom + height)), false),
+ (Point::new(Mm(x), Mm(y_bottom + height)), false),
+ ];
+
+ let polygon = printpdf::Polygon {
+ rings: vec![points],
+ mode: PaintMode::Fill,
+ winding_order: WindingOrder::NonZero,
+ };
+
+ layer.add_polygon(polygon);
+ }
+
+ fn layout_top_to_pdf_bottom(y_top: f32, element_height: f32, page_height: f32) -> f32 {
+ page_height - y_top - element_height
+ }
+
+ fn layout_text_baseline(y_top: f32, font_size_pt: f32, page_height: f32) -> f32 {
+ let text_height_mm = font_size_pt * POINTS_TO_MM;
+ let bottom = Self::layout_top_to_pdf_bottom(y_top, text_height_mm, page_height);
+ bottom + text_height_mm * TEXT_DESCENT_RATIO
+ }
+
+ // Removed resolve_rect_width: Rect.width is now numeric grid units directly
+
+ pub fn from_json(raw: &str) -> Result<Self> {
+ let json = if raw.trim_start().starts_with('{') {
+ raw.to_string()
+ } else {
+ // Attempt base64 decode; fall back to raw
+ match base64::engine::general_purpose::STANDARD.decode(raw) {
+ Ok(bytes) => String::from_utf8(bytes).unwrap_or_else(|_| raw.to_string()),
+ Err(_) => raw.to_string(),
+ }
+ };
+ let layout: LabelLayout =
+ serde_json::from_str(&json).context("Failed to parse label layout JSON")?;
+ Ok(LabelRenderer::new(layout))
+ }
+
+ pub fn render_preview(
+ &self,
+ ui: &mut egui::Ui,
+ data: &HashMap<String, String>,
+ preview_scale: f32,
+ printer_settings: Option<&PrinterSettings>,
+ ) -> Result<()> {
+ let bounds = self.calculate_layout_bounds()?;
+ let transform = LayoutTransform::new(bounds, printer_settings)?;
+ let canvas_w_px = (transform.page_width * preview_scale).ceil().max(1.0);
+ let canvas_h_px = (transform.page_height * preview_scale).ceil().max(1.0);
+ let (resp, painter) =
+ ui.allocate_painter(egui::vec2(canvas_w_px, canvas_h_px), egui::Sense::hover());
+ let rect = resp.rect;
+ // Background
+ let page_bg = egui::Color32::from_rgb(250, 250, 250);
+ painter.rect_filled(rect, egui::CornerRadius::ZERO, page_bg);
+ // Draw printable area to visualize margins
+ let printable_rect = egui::Rect::from_min_size(
+ egui::pos2(
+ rect.min.x + transform.margins.left * preview_scale,
+ rect.min.y + transform.margins.top * preview_scale,
+ ),
+ egui::vec2(
+ transform.printable_width * preview_scale,
+ transform.printable_height * preview_scale,
+ ),
+ );
+ let printable_bg = self
+ .layout
+ .background
+ .as_deref()
+ .and_then(Self::parse_hex_color)
+ .unwrap_or(egui::Color32::WHITE);
+ painter.rect_filled(printable_rect, egui::CornerRadius::ZERO, printable_bg);
+
+ for element in &self.layout.elements {
+ match element {
+ LabelElement::Text {
+ field,
+ x,
+ y,
+ font_size,
+ color,
+ max_width,
+ wrap,
+ ..
+ } => {
+ let value = Self::resolve_field(field, data);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_top_mm = transform.y_mm(*y);
+ let font_pt = (*font_size * transform.uniform_scale).max(0.5);
+ let color32 = color
+ .as_deref()
+ .and_then(Self::parse_hex_color)
+ .unwrap_or(egui::Color32::BLACK);
+ let line_height_mm = font_pt * POINTS_TO_MM * 1.2;
+ let lines: Vec<String> = if wrap.unwrap_or(false) && max_width.is_some() {
+ Self::wrap_lines(&value, transform.width_mm(max_width.unwrap()), font_pt)
+ } else {
+ vec![value]
+ };
+ for (i, line) in lines.iter().enumerate() {
+ let line_y_mm = y_top_mm + i as f32 * line_height_mm;
+ let baseline_mm =
+ line_y_mm + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO);
+ let baseline_px = baseline_mm * preview_scale;
+ painter.text(
+ egui::pos2(rect.min.x + x_px, rect.min.y + baseline_px),
+ egui::Align2::LEFT_BOTTOM,
+ line,
+ egui::FontId::proportional(font_pt),
+ color32,
+ );
+ }
+ }
+ LabelElement::QrCode {
+ field,
+ x,
+ y,
+ size,
+ show_text,
+ ..
+ } => {
+ let value = Self::resolve_field(field, data);
+ let side_mm = transform.uniform_mm(*size).max(0.1);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_px = transform.y_mm(*y) * preview_scale;
+ let side_px = side_mm * preview_scale;
+ // Simple placeholder squares for modules (not rendering actual QR in preview for speed)
+ painter.rect_filled(
+ egui::Rect::from_min_size(
+ egui::pos2(rect.min.x + x_px, rect.min.y + y_px),
+ egui::vec2(side_px, side_px),
+ ),
+ egui::CornerRadius::ZERO,
+ egui::Color32::BLACK,
+ );
+ if show_text.unwrap_or(false) {
+ let font_pt = 10.0 * transform.uniform_scale;
+ let text_y_px = rect.min.y
+ + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale)
+ * preview_scale
+ + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale;
+ painter.text(
+ egui::pos2(rect.min.x + x_px, text_y_px),
+ egui::Align2::LEFT_BOTTOM,
+ &value,
+ egui::FontId::proportional(font_pt),
+ egui::Color32::BLACK,
+ );
+ }
+ }
+ LabelElement::DataMatrix {
+ field,
+ x,
+ y,
+ size,
+ show_text,
+ } => {
+ let value = Self::resolve_field(field, data);
+ let side_mm = transform.uniform_mm(*size).max(0.1);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_px = transform.y_mm(*y) * preview_scale;
+ let side_px = side_mm * preview_scale;
+ painter.rect_filled(
+ egui::Rect::from_min_size(
+ egui::pos2(rect.min.x + x_px, rect.min.y + y_px),
+ egui::vec2(side_px, side_px),
+ ),
+ egui::CornerRadius::ZERO,
+ egui::Color32::DARK_GRAY,
+ );
+ if show_text.unwrap_or(false) {
+ let font_pt = 8.0 * transform.uniform_scale;
+ let text_y_px = rect.min.y
+ + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale)
+ * preview_scale
+ + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale;
+ painter.text(
+ egui::pos2(rect.min.x + x_px, text_y_px),
+ egui::Align2::LEFT_BOTTOM,
+ &value,
+ egui::FontId::proportional(font_pt),
+ egui::Color32::BLACK,
+ );
+ }
+ }
+ LabelElement::Barcode {
+ field,
+ x,
+ y,
+ width,
+ height,
+ show_text,
+ ..
+ } => {
+ let value = Self::resolve_field(field, data);
+ let w_mm = transform.width_mm(*width).max(0.1);
+ let h_mm = transform.height_mm(*height).max(0.1);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_px = transform.y_mm(*y) * preview_scale;
+ let w_px = w_mm * preview_scale;
+ let h_px = h_mm * preview_scale;
+ painter.rect_filled(
+ egui::Rect::from_min_size(
+ egui::pos2(rect.min.x + x_px, rect.min.y + y_px),
+ egui::vec2(w_px, h_px),
+ ),
+ egui::CornerRadius::ZERO,
+ egui::Color32::BLACK,
+ );
+ if show_text.unwrap_or(false) {
+ let font_pt = 8.0 * transform.uniform_scale;
+ let text_y_px = rect.min.y
+ + (transform.y_mm(*y) + h_mm + 1.5 * transform.uniform_scale)
+ * preview_scale
+ + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale;
+ painter.text(
+ egui::pos2(rect.min.x + x_px, text_y_px),
+ egui::Align2::LEFT_BOTTOM,
+ &value,
+ egui::FontId::proportional(font_pt),
+ egui::Color32::BLACK,
+ );
+ }
+ }
+ LabelElement::Rect {
+ x,
+ y,
+ width,
+ height,
+ fill,
+ } => {
+ let w_mm = transform.width_mm(*width).max(0.1);
+ let h_mm = transform.height_mm(*height).max(0.1);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_px = transform.y_mm(*y) * preview_scale;
+ let w_px = w_mm * preview_scale;
+ let h_px = h_mm * preview_scale;
+ let color32 = fill
+ .as_deref()
+ .and_then(Self::parse_hex_color)
+ .unwrap_or(egui::Color32::from_gray(180));
+ painter.rect_filled(
+ egui::Rect::from_min_size(
+ egui::pos2(rect.min.x + x_px, rect.min.y + y_px),
+ egui::vec2(w_px, h_px),
+ ),
+ egui::CornerRadius::ZERO,
+ color32,
+ );
+ }
+ LabelElement::Svg {
+ x,
+ y,
+ width,
+ height,
+ ..
+ } => {
+ let w_mm = transform.width_mm(*width).max(0.1);
+ let h_mm = transform.height_mm(*height).max(0.1);
+ let x_px = transform.x_mm(*x) * preview_scale;
+ let y_px = transform.y_mm(*y) * preview_scale;
+ let w_px = w_mm * preview_scale;
+ let h_px = h_mm * preview_scale;
+ painter.rect_filled(
+ egui::Rect::from_min_size(
+ egui::pos2(rect.min.x + x_px, rect.min.y + y_px),
+ egui::vec2(w_px, h_px),
+ ),
+ egui::CornerRadius::ZERO,
+ egui::Color32::from_gray(200),
+ );
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn prepare_code128_payload(data: &str) -> Option<String> {
+ // Strip BOM and surrounding whitespace
+ let mut s = data.trim().trim_start_matches('\u{FEFF}').to_string();
+ if s.is_empty() {
+ return None;
+ }
+
+ // Allow user-provided advanced Code128 sequence (already has start set char)
+ if let Some(first) = s.chars().next() {
+ if matches!(first, 'À' | 'Ɓ' | 'Ć') {
+ // Minimal length check (library requires at least 2 chars total)
+ if s.len() >= 2 {
+ return Some(s);
+ } else {
+ return None;
+ }
+ }
+ }
+
+ // Remove internal whitespace
+ s.retain(|c| !c.is_whitespace());
+ if s.is_empty() {
+ return None;
+ }
+
+ // Pure digits: use Code Set C (double-density). Must be even length; pad leading 0 if needed.
+ if s.chars().all(|c| c.is_ascii_digit()) {
+ if s.len() % 2 == 1 {
+ s.insert(0, '0');
+ }
+ // Prefix with Set C start char 'Ć'
+ return Some(format!("Ć{}", s));
+ }
+
+ // General printable ASCII: choose Set B start ('Ɓ'). Filter to printable 32..=126.
+ let mut cleaned = String::new();
+ for ch in s.chars() {
+ let code = ch as u32;
+ if (32..=126).contains(&code) {
+ cleaned.push(ch);
+ }
+ }
+ if cleaned.is_empty() {
+ return None;
+ }
+ Some(format!("Ɓ{}", cleaned))
+ }
+
+ // Naive word-wrap: estimate character width ~= 0.55 * font_size_pt * POINTS_TO_MM
+ fn wrap_lines(text: &str, max_width_mm: f32, font_size_pt: f32) -> Vec<String> {
+ let approx_char_mm = font_size_pt * POINTS_TO_MM * 0.55;
+ if approx_char_mm <= 0.0 || max_width_mm <= 0.0 {
+ return vec![text.to_string()];
+ }
+ let max_chars = (max_width_mm / approx_char_mm).floor().max(1.0) as usize;
+ let mut lines = Vec::new();
+ let mut current = String::new();
+ for word in text.split_whitespace() {
+ if current.is_empty() {
+ current.push_str(word);
+ continue;
+ }
+ if current.len() + 1 + word.len() <= max_chars {
+ current.push(' ');
+ current.push_str(word);
+ } else {
+ lines.push(std::mem::take(&mut current));
+ current.push_str(word);
+ }
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ if lines.is_empty() {
+ lines.push(String::new());
+ }
+ lines
+ }
+
+ // Decode data URI to raw SVG XML string
+ fn decode_svg_data_uri(data_uri: &str) -> Option<String> {
+ if let Some(idx) = data_uri.find(',') {
+ let (header, payload) = data_uri.split_at(idx + 1);
+ if header.contains("base64") {
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(payload)
+ .ok()?;
+ String::from_utf8(bytes).ok()
+ } else {
+ Some(payload.to_string())
+ }
+ } else {
+ if data_uri.contains("<svg") {
+ Some(data_uri.to_string())
+ } else {
+ None
+ }
+ }
+ }
+
+ fn rasterize_svg_to_rgba(svg_xml: &str, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
+ use tiny_skia::Pixmap;
+ use usvg::Options;
+
+ let opt = Options::default();
+ let tree = usvg::Tree::from_str(svg_xml, &opt).ok()?;
+ let mut pixmap = Pixmap::new(target_w, target_h)?;
+ // Compute uniform scale to fit preserving aspect
+ let view_size = tree.size();
+ let sx = target_w as f32 / view_size.width();
+ let sy = target_h as f32 / view_size.height();
+ let scale = sx.min(sy);
+ let transform = tiny_skia::Transform::from_scale(scale, scale);
+ // Render using resvg
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ Some(pixmap.data().to_vec())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn code128_numeric_even_len_encodes() {
+ let raw = "75650012"; // even length digits
+ let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload");
+ assert!(payload.starts_with('Ć'));
+ let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128");
+ assert!(!code.encode().is_empty());
+ }
+
+ #[test]
+ fn code128_numeric_odd_len_padded() {
+ let raw = "123";
+ let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload");
+ assert!(payload.starts_with('Ć'));
+ // Collect digits after the first unicode character (start set)
+ let digits: String = payload.chars().skip(1).collect();
+ assert_eq!(digits.len() % 2, 0);
+ let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128");
+ assert!(!code.encode().is_empty());
+ }
+
+ #[test]
+ fn code128_basic_ascii_encodes() {
+ let payload = LabelRenderer::prepare_code128_payload("HELLO-123").expect("payload");
+ assert!(payload.starts_with('Ɓ'));
+ let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128");
+ assert!(!code.encode().is_empty());
+ }
+
+ #[test]
+ fn code11_accepts_digits_and_dash() {
+ use barcoders::sym::code11::Code11;
+ // Valid payload containing digits and dash
+ let c = Code11::new("123-45").expect("encode code11");
+ assert!(!c.encode().is_empty());
+ // Library should reject invalid characters; ensure it errors
+ assert!(Code11::new("12A45").is_err());
+ }
+
+ #[test]
+ fn datamatrix_encodes_nonempty_bitmap() {
+ use datamatrix::{DataMatrix, SymbolList};
+ let dm =
+ DataMatrix::encode_str("DM-OK-123", SymbolList::default()).expect("encode datamatrix");
+ let bmp = dm.bitmap();
+ assert!(bmp.width() > 0 && bmp.height() > 0);
+ assert!(bmp.pixels().next().is_some());
+ }
+
+ #[test]
+ fn layout_deserialize_show_text_flags_raw_and_base64() {
+ // Minimal layout exercising showText flags across elements
+ let raw_json = r##"{
+ "background": "#FFFFFF",
+ "elements": [
+ {"type": "qrcode", "field": "A", "x": 5, "y": 5, "size": 20, "showText": true},
+ {"type": "datamatrix", "field": "B", "x": 30, "y": 5, "size": 20, "showText": false},
+ {"type": "barcode", "field": "C", "x": 5, "y": 30, "width": 40, "height": 12, "format": "code128", "showText": true}
+ ]
+ }"##;
+
+ // Parse raw
+ let r1 = LabelRenderer::from_json(raw_json).expect("raw parse");
+ assert_eq!(r1.layout.elements.len(), 3);
+
+ // Parse base64 of same JSON
+ let b64 = base64::engine::general_purpose::STANDARD.encode(raw_json);
+ let r2 = LabelRenderer::from_json(&b64).expect("b64 parse");
+ assert_eq!(r2.layout.elements.len(), 3);
+
+ // Spot-check variant fields carry show_text flags via serde mapping
+ match &r1.layout.elements[0] {
+ LabelElement::QrCode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true),
+ _ => panic!("expected qrcode"),
+ }
+ match &r1.layout.elements[1] {
+ LabelElement::DataMatrix { show_text, .. } => {
+ assert_eq!(show_text.unwrap_or(true), false)
+ }
+ _ => panic!("expected datamatrix"),
+ }
+ match &r1.layout.elements[2] {
+ LabelElement::Barcode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true),
+ _ => panic!("expected barcode"),
+ }
+ }
+}