diff options
| author | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:51:15 +0100 |
|---|---|---|
| committer | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:51:15 +0100 |
| commit | 8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch) | |
| tree | ffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/print/renderer.rs | |
Diffstat (limited to 'src/core/print/renderer.rs')
| -rw-r--r-- | src/core/print/renderer.rs | 1537 |
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"), + } + } +} |
