Skip to main content

zpl_forge/forge/
pdf_native.rs

1//! Native vector PDF rendering backend for ZPL label output.
2//!
3//! This backend renders text, shapes, barcodes and images as native PDF
4//! vector operations for maximum quality and minimal file size.
5
6use std::cmp::max;
7use std::collections::{HashMap, HashSet};
8use std::io::Write;
9use std::sync::Arc;
10
11use ab_glyph::{Font, FontArc, PxScale, ScaleFont};
12use base64::{Engine as _, engine::general_purpose};
13use flate2::Compression;
14use flate2::write::ZlibEncoder;
15use lopdf::{Document, FontData, Object, Stream, dictionary};
16use rxing::common::BitMatrix;
17use rxing::{BarcodeFormat, EncodeHintType, EncodeHintValue, EncodeHints};
18
19use super::{barcode_1d_format, barcode_cache};
20use crate::engine::{Barcode1DKind, FontManager, ZplForgeBackend};
21use crate::{ZplError, ZplResult};
22
23/// Bézier control-point factor for approximating a quarter-circle arc.
24const KAPPA: f64 = 0.5522847498;
25
26// ─── WinAnsi (CP1252) encoding ──────────────────────────────────────────────
27//
28// Embedded fonts are declared with /Encoding WinAnsiEncoding, so text shown
29// with `Tj` must be CP1252 bytes — not UTF-8. This is what makes accented
30// characters (ñ, á, é...) render and copy correctly.
31
32/// Unicode characters for CP1252 codes 0x80..=0x9F (`\u{0}` = undefined).
33const CP1252_80_9F: [char; 32] = [
34    '\u{20AC}', '\u{0}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
35    '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{0}', '\u{017D}', '\u{0}',
36    '\u{0}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
37    '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{0}', '\u{017E}', '\u{0178}',
38];
39
40/// Encodes a Unicode char to its CP1252 byte, when representable.
41fn char_to_winansi(c: char) -> Option<u8> {
42    let cp = c as u32;
43    match cp {
44        0x20..=0x7E => Some(cp as u8),
45        // CP1252 0xA0..=0xFF is identical to Latin-1.
46        0xA0..=0xFF => Some(cp as u8),
47        _ => CP1252_80_9F
48            .iter()
49            .position(|&m| m == c && m != '\u{0}')
50            .map(|i| 0x80 + i as u8),
51    }
52}
53
54/// Decodes a CP1252 byte back to its Unicode char, when defined.
55fn winansi_to_char(code: u8) -> Option<char> {
56    match code {
57        0x20..=0x7E => Some(code as char),
58        0xA0..=0xFF => Some(code as char),
59        0x80..=0x9F => {
60            let c = CP1252_80_9F[(code - 0x80) as usize];
61            (c != '\u{0}').then_some(c)
62        }
63        _ => None,
64    }
65}
66
67/// Builds a ToUnicode CMap stream body for the WinAnsi code range.
68fn build_tounicode_cmap() -> Vec<u8> {
69    let mut s = String::with_capacity(4096);
70    s.push_str(
71        "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
72         /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
73         /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
74         1 begincodespacerange\n<20> <FF>\nendcodespacerange\n",
75    );
76    let entries: Vec<(u8, char)> = (0x20..=0xFFu32)
77        .filter_map(|c| winansi_to_char(c as u8).map(|ch| (c as u8, ch)))
78        .collect();
79    for chunk in entries.chunks(100) {
80        s.push_str(&format!("{} beginbfchar\n", chunk.len()));
81        for (code, ch) in chunk {
82            s.push_str(&format!("<{:02X}> <{:04X}>\n", code, *ch as u32));
83        }
84        s.push_str("endbfchar\n");
85    }
86    s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
87    s.into_bytes()
88}
89
90// ─── Internal types ─────────────────────────────────────────────────────────
91
92/// Collected image data to be embedded as a PDF XObject during [`PdfNativeBackend::finalize`].
93struct ImageXObject {
94    name: String,
95    data: Vec<u8>,
96    width: u32,
97    height: u32,
98    /// `true` for 1-bit stencil masks (`^GF` bitmaps), `false` for 8-bit RGB.
99    is_mask: bool,
100}
101
102// ─── Public struct ──────────────────────────────────────────────────────────
103
104/// A rendering backend that produces PDF documents with native vector operations.
105///
106/// Text is rendered using an embedded TrueType font, shapes are drawn as PDF
107/// paths with Bézier curves, and barcodes are composed of filled rectangles.
108/// Bitmap data (graphic fields, custom images) is embedded as compressed
109/// XObject image streams.
110pub struct PdfNativeBackend {
111    width_dots: f64,
112    height_dots: f64,
113    width_pt: f64,
114    height_pt: f64,
115    resolution: f32,
116    /// `72.0 / dpi` – multiplier that converts dots to PDF points.
117    scale: f64,
118    /// Raw PDF content-stream bytes for the page currently being drawn.
119    content: Vec<u8>,
120    /// Content streams of pages already finished via [`ZplForgeBackend::new_page`].
121    finished_pages: Vec<Vec<u8>>,
122    font_manager: Option<Arc<FontManager>>,
123    images: Vec<ImageXObject>,
124    image_counter: usize,
125    /// Tracks which font identifiers (e.g. 'A', 'B', '0') have been used during rendering.
126    used_fonts: HashSet<char>,
127    compression: Compression,
128    /// Optional document title for the PDF Info dictionary.
129    title: Option<String>,
130    /// Solid rectangles painted on the current page, in dots, with their
131    /// fill colour. Used to compute `^FR` (reverse print) geometrically —
132    /// blend modes are unreliable across viewers and print RIPs.
133    #[allow(clippy::type_complexity)]
134    backdrop_rects: Vec<(f64, f64, f64, f64, (f64, f64, f64))>,
135}
136
137impl Default for PdfNativeBackend {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143// ─── Construction ───────────────────────────────────────────────────────────
144
145impl PdfNativeBackend {
146    /// Creates a new `PdfNativeBackend` with default settings.
147    pub fn new() -> Self {
148        Self {
149            width_dots: 0.0,
150            height_dots: 0.0,
151            width_pt: 0.0,
152            height_pt: 0.0,
153            resolution: 0.0,
154            scale: 0.0,
155            content: Vec::with_capacity(4096),
156            finished_pages: Vec::new(),
157            font_manager: None,
158            images: Vec::new(),
159            image_counter: 0,
160            used_fonts: HashSet::new(),
161            compression: Compression::default(),
162            title: None,
163            backdrop_rects: Vec::new(),
164        }
165    }
166
167    /// Sets the zlib compression level for the PDF output (builder pattern).
168    pub fn with_compression(mut self, compression: Compression) -> Self {
169        self.compression = compression;
170        self
171    }
172
173    /// Sets the document title written to the PDF Info dictionary (builder pattern).
174    pub fn with_title(mut self, title: impl Into<String>) -> Self {
175        self.title = Some(title.into());
176        self
177    }
178}
179
180// ─── Private helpers ────────────────────────────────────────────────────────
181
182impl PdfNativeBackend {
183    // ── coordinate helpers ──────────────────────────────────────────
184
185    /// Convert a measurement in dots to PDF points.
186    #[inline]
187    fn d2pt(&self, dots: f64) -> f64 {
188        dots * self.scale
189    }
190
191    /// ZPL x-dot → PDF x-point (origin stays at the left).
192    #[inline]
193    fn x_pt(&self, x: f64) -> f64 {
194        x * self.scale
195    }
196
197    /// PDF y for the **bottom** edge of an object whose top-left is at ZPL row
198    /// `y` with height `h` (both in dots).
199    #[inline]
200    fn y_pt_bottom(&self, y: f64, h: f64) -> f64 {
201        self.height_pt - (y + h) * self.scale
202    }
203
204    // ── colour helpers ─────────────────────────────────────────────
205
206    /// Parse `#RRGGBB` / `#RGB` into `(r, g, b)` in 0.0 – 1.0.  Defaults to
207    /// black when the string is absent or malformed.
208    fn parse_hex_color_f64(color: &Option<String>) -> (f64, f64, f64) {
209        if let Some(hex) = color {
210            let hex = hex.trim_start_matches('#');
211            if hex.len() == 6 {
212                if let (Ok(r), Ok(g), Ok(b)) = (
213                    u8::from_str_radix(&hex[0..2], 16),
214                    u8::from_str_radix(&hex[2..4], 16),
215                    u8::from_str_radix(&hex[4..6], 16),
216                ) {
217                    return (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
218                }
219            } else if hex.len() == 3
220                && let (Ok(r), Ok(g), Ok(b)) = (
221                    u8::from_str_radix(&hex[0..1], 16),
222                    u8::from_str_radix(&hex[1..2], 16),
223                    u8::from_str_radix(&hex[2..3], 16),
224                )
225            {
226                return (
227                    r as f64 * 17.0 / 255.0,
228                    g as f64 * 17.0 / 255.0,
229                    b as f64 * 17.0 / 255.0,
230                );
231            }
232        }
233        (0.0, 0.0, 0.0)
234    }
235
236    /// Resolve the *draw* and *clear* colours for a graphic element.
237    ///
238    /// Follows the same logic as `PngBackend`:
239    /// - custom hex colour → (custom, white)
240    /// - `'B'` → (black, white)
241    /// - `'W'` → (white, black)
242    fn resolve_colors(
243        color: char,
244        custom_color: &Option<String>,
245    ) -> ((f64, f64, f64), (f64, f64, f64)) {
246        if custom_color.is_some() {
247            (Self::parse_hex_color_f64(custom_color), (1.0, 1.0, 1.0))
248        } else if color == 'B' {
249            ((0.0, 0.0, 0.0), (1.0, 1.0, 1.0))
250        } else {
251            ((1.0, 1.0, 1.0), (0.0, 0.0, 0.0))
252        }
253    }
254
255    // ── low-level PDF operation emitters ────────────────────────────
256    //
257    // Operators are written directly as content-stream bytes instead of
258    // accumulating `lopdf::content::Operation` values: barcodes and QR codes
259    // emit thousands of `re` rectangles, and the per-operation allocations
260    // dominated render time.
261
262    /// Write a number with up to 3 decimals, trimming trailing zeros.
263    fn put_num(buf: &mut Vec<u8>, v: f64) {
264        if v == v.trunc() && v.abs() < 1e12 {
265            let mut itoa = [0u8; 20];
266            let mut n = v as i64;
267            if n < 0 {
268                buf.push(b'-');
269                n = -n;
270            }
271            let mut i = itoa.len();
272            loop {
273                i -= 1;
274                itoa[i] = b'0' + (n % 10) as u8;
275                n /= 10;
276                if n == 0 {
277                    break;
278                }
279            }
280            buf.extend_from_slice(&itoa[i..]);
281        } else {
282            let mut s = format!("{:.3}", v);
283            while s.ends_with('0') {
284                s.pop();
285            }
286            if s.ends_with('.') {
287                s.pop();
288            }
289            buf.extend_from_slice(s.as_bytes());
290        }
291    }
292
293    /// Emit `n1 n2 ... op\n`.
294    fn emit_nums(&mut self, nums: &[f64], op: &str) {
295        for n in nums {
296            Self::put_num(&mut self.content, *n);
297            self.content.push(b' ');
298        }
299        self.content.extend_from_slice(op.as_bytes());
300        self.content.push(b'\n');
301    }
302
303    /// Emit a bare operator: `op\n`.
304    fn emit_op(&mut self, op: &str) {
305        self.content.extend_from_slice(op.as_bytes());
306        self.content.push(b'\n');
307    }
308
309    /// Emit `/Name op\n`.
310    fn emit_name_op(&mut self, name: &str, op: &str) {
311        self.content.push(b'/');
312        self.content.extend_from_slice(name.as_bytes());
313        self.content.push(b' ');
314        self.content.extend_from_slice(op.as_bytes());
315        self.content.push(b'\n');
316    }
317
318    /// Emit `(escaped) Tj\n`, encoding the text as WinAnsi (CP1252) to match
319    /// the embedded fonts' /Encoding. Unmappable characters become '?'.
320    fn emit_tj(&mut self, text: &str) {
321        self.content.push(b'(');
322        for c in text.chars() {
323            let b = char_to_winansi(c).unwrap_or(b'?');
324            match b {
325                b'(' | b')' | b'\\' => {
326                    self.content.push(b'\\');
327                    self.content.push(b);
328                }
329                _ => self.content.push(b),
330            }
331        }
332        self.content.extend_from_slice(b") Tj\n");
333    }
334
335    fn set_fill_color(&mut self, r: f64, g: f64, b: f64) {
336        self.emit_nums(&[r, g, b], "rg");
337    }
338
339    fn save_state(&mut self) {
340        self.emit_op("q");
341    }
342
343    fn restore_state(&mut self) {
344        self.emit_op("Q");
345    }
346
347    // ── reverse-print (geometric) ──────────────────────────────────
348    //
349    // ZPL `^FR` inverts the element against whatever lies beneath it. Instead
350    // of relying on the `Difference` blend mode (poorly supported by Quartz/
351    // Preview and ignored by many print RIPs), the backend tracks the solid
352    // rectangles already painted and repaints their inverse inside a clip
353    // shaped like the reversed element.
354
355    /// Records a solid filled rectangle (in dots) as part of the backdrop.
356    fn track_backdrop_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: (f64, f64, f64)) {
357        if w > 0.0 && h > 0.0 {
358            self.backdrop_rects.push((x, y, w, h, color));
359        }
360    }
361
362    /// Topmost backdrop colour at a point (in dots); white when unpainted.
363    fn backdrop_color_at(&self, px: f64, py: f64) -> (f64, f64, f64) {
364        let mut color = (1.0, 1.0, 1.0);
365        for (rx, ry, rw, rh, c) in &self.backdrop_rects {
366            if px >= *rx && px < rx + rw && py >= *ry && py < ry + rh {
367                color = *c;
368            }
369        }
370        color
371    }
372
373    /// Paints the inverse of the backdrop across the element bounding box
374    /// `(ex, ey, ew, eh)` in dots. The caller must have already established a
375    /// clipping path shaped like the reversed element.
376    fn fill_inverse_backdrop(&mut self, ex: f64, ey: f64, ew: f64, eh: f64) {
377        // Unpainted page is white → its inverse is black.
378        self.set_fill_color(0.0, 0.0, 0.0);
379        let px = self.x_pt(ex);
380        let py = self.y_pt_bottom(ey, eh);
381        let (pw, ph) = (self.d2pt(ew), self.d2pt(eh));
382        self.emit_nums(&[px, py, pw, ph], "re");
383        self.emit_op("f");
384
385        // Repaint intersections with tracked rects using their inverse, in
386        // z-order so later fills win exactly like the original painting did.
387        let rects = self.backdrop_rects.clone();
388        for (rx, ry, rw, rh, (cr, cg, cb)) in rects {
389            let ix0 = rx.max(ex);
390            let iy0 = ry.max(ey);
391            let ix1 = (rx + rw).min(ex + ew);
392            let iy1 = (ry + rh).min(ey + eh);
393            if ix1 > ix0 && iy1 > iy0 {
394                self.set_fill_color(1.0 - cr, 1.0 - cg, 1.0 - cb);
395                let px = self.x_pt(ix0);
396                let py = self.y_pt_bottom(iy0, iy1 - iy0);
397                self.emit_nums(&[px, py, self.d2pt(ix1 - ix0), self.d2pt(iy1 - iy0)], "re");
398                self.emit_op("f");
399            }
400        }
401    }
402
403    // ── path construction ──────────────────────────────────────────
404
405    /// Append path operators for a rounded rectangle.
406    ///
407    /// `(x, y)` is the **bottom-left** corner in PDF coordinates; `w` and `h`
408    /// extend to the right and upward.
409    fn push_rounded_rect_path(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64) {
410        let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
411        if r < 0.001 {
412            self.emit_nums(&[x, y, w, h], "re");
413            return;
414        }
415        let kr = KAPPA * r;
416        // bottom-left → right along bottom edge
417        self.emit_nums(&[x + r, y], "m");
418        self.emit_nums(&[x + w - r, y], "l");
419        // bottom-right corner
420        self.emit_nums(&[x + w - r + kr, y, x + w, y + r - kr, x + w, y + r], "c");
421        // right edge upward
422        self.emit_nums(&[x + w, y + h - r], "l");
423        // top-right corner
424        self.emit_nums(
425            &[
426                x + w,
427                y + h - r + kr,
428                x + w - r + kr,
429                y + h,
430                x + w - r,
431                y + h,
432            ],
433            "c",
434        );
435        // top edge leftward
436        self.emit_nums(&[x + r, y + h], "l");
437        // top-left corner
438        self.emit_nums(&[x + r - kr, y + h, x, y + h - r + kr, x, y + h - r], "c");
439        // left edge downward
440        self.emit_nums(&[x, y + r], "l");
441        // bottom-left corner
442        self.emit_nums(&[x, y + r - kr, x + r - kr, y, x + r, y], "c");
443        self.emit_op("h");
444    }
445
446    /// Append path operators for an ellipse centred at `(cx, cy)` with radii
447    /// `(rx, ry)`, approximated by four cubic Bézier curves.
448    fn push_ellipse_path(&mut self, cx: f64, cy: f64, rx: f64, ry: f64) {
449        let kx = KAPPA * rx;
450        let ky = KAPPA * ry;
451        // start at 3-o'clock
452        self.emit_nums(&[cx + rx, cy], "m");
453        // → 12-o'clock
454        self.emit_nums(&[cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry], "c");
455        // → 9-o'clock
456        self.emit_nums(&[cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy], "c");
457        // → 6-o'clock
458        self.emit_nums(&[cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry], "c");
459        // → back to 3-o'clock
460        self.emit_nums(&[cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy], "c");
461        self.emit_op("h");
462    }
463
464    // ── font / text helpers ────────────────────────────────────────
465
466    fn get_font_arc(&self, font_char: char) -> ZplResult<&ab_glyph::FontArc> {
467        let fm = self
468            .font_manager
469            .as_ref()
470            .ok_or_else(|| ZplError::FontError("Font manager not initialized".into()))?;
471        fm.get_font(&font_char.to_string())
472            .or_else(|| fm.get_font("0"))
473            .ok_or_else(|| ZplError::FontError(format!("Font not found: {}", font_char)))
474    }
475
476    fn get_text_width(
477        &self,
478        text: &str,
479        font_char: char,
480        height: Option<u32>,
481        width: Option<u32>,
482    ) -> u32 {
483        let font = match self.get_font_arc(font_char) {
484            Ok(f) => f,
485            Err(_) => return 0,
486        };
487        let scale_y = height.unwrap_or(9) as f32;
488        let scale_x = width.unwrap_or(scale_y as u32) as f32;
489        let scale = PxScale {
490            x: scale_x,
491            y: scale_y,
492        };
493        let scaled = font.as_scaled(scale);
494        let mut w = 0.0_f32;
495        let mut last_glyph = None;
496        for c in text.chars() {
497            let gid = font.glyph_id(c);
498            if let Some(prev) = last_glyph {
499                w += scaled.kern(prev, gid);
500            }
501            w += scaled.h_advance(gid);
502            last_glyph = Some(gid);
503        }
504        w.ceil() as u32
505    }
506
507    // ── image embedding ────────────────────────────────────────────
508
509    /// Store raw RGB image data as a future XObject and emit the `cm` + `Do`
510    /// operators that place it on the page.
511    fn embed_rgb_image(
512        &mut self,
513        x_dots: f64,
514        y_dots: f64,
515        img_w: u32,
516        img_h: u32,
517        rgb_data: Vec<u8>,
518    ) {
519        let name = format!("Im{}", self.image_counter);
520        self.image_counter += 1;
521
522        let px = self.x_pt(x_dots);
523        let py = self.y_pt_bottom(y_dots, img_h as f64);
524        let pw = self.d2pt(img_w as f64);
525        let ph = self.d2pt(img_h as f64);
526
527        self.save_state();
528        self.emit_nums(&[pw, 0.0, 0.0, ph, px, py], "cm");
529        self.emit_name_op(&name, "Do");
530        self.restore_state();
531
532        self.images.push(ImageXObject {
533            name,
534            data: rgb_data,
535            width: img_w,
536            height: img_h,
537            is_mask: false,
538        });
539    }
540
541    /// Store 1-bit bitmap data as a future stencil-mask XObject and emit the
542    /// operators that paint it on the page. Set bits (ZPL black) are painted
543    /// with the current fill colour; clear bits are transparent.
544    fn embed_mask_image(
545        &mut self,
546        x_dots: f64,
547        y_dots: f64,
548        img_w: u32,
549        img_h: u32,
550        bits: Vec<u8>,
551        reverse_print: bool,
552    ) {
553        let name = format!("Im{}", self.image_counter);
554        self.image_counter += 1;
555
556        let px = self.x_pt(x_dots);
557        let py = self.y_pt_bottom(y_dots, img_h as f64);
558        let pw = self.d2pt(img_w as f64);
559        let ph = self.d2pt(img_h as f64);
560
561        self.save_state();
562        if reverse_print {
563            // Stencil masks can't be clipped per-pixel without SMasks, so
564            // approximate: paint with the inverse of the backdrop colour at
565            // the bitmap centre.
566            let (br, bg, bb) =
567                self.backdrop_color_at(x_dots + img_w as f64 / 2.0, y_dots + img_h as f64 / 2.0);
568            self.set_fill_color(1.0 - br, 1.0 - bg, 1.0 - bb);
569        } else {
570            self.set_fill_color(0.0, 0.0, 0.0);
571        }
572        self.emit_nums(&[pw, 0.0, 0.0, ph, px, py], "cm");
573        self.emit_name_op(&name, "Do");
574        self.restore_state();
575
576        self.images.push(ImageXObject {
577            name,
578            data: bits,
579            width: img_w,
580            height: img_h,
581            is_mask: true,
582        });
583    }
584
585    // ── barcode orientation transforms ─────────────────────────────
586
587    /// Map a local rectangle inside a 1-D barcode to absolute dot coordinates
588    /// according to the requested orientation.
589    ///
590    /// Returns `(abs_x, abs_y, width, height)` – all in dots.
591    #[allow(clippy::too_many_arguments)]
592    fn transform_1d_bar(
593        orientation: char,
594        base_x: u32,
595        base_y: u32,
596        lx: i32,
597        ly: i32,
598        w: u32,
599        h: u32,
600        bw: u32,
601        bh: u32,
602    ) -> (i32, i32, u32, u32) {
603        match orientation {
604            'R' => {
605                let nx = bh as i32 - (ly + h as i32);
606                let ny = lx;
607                (base_x as i32 + nx, base_y as i32 + ny, h, w)
608            }
609            'I' => {
610                let nx = bw as i32 - (lx + w as i32);
611                let ny = bh as i32 - (ly + h as i32);
612                (base_x as i32 + nx, base_y as i32 + ny, w, h)
613            }
614            'B' => {
615                let nx = ly;
616                let ny = bw as i32 - (lx + w as i32);
617                (base_x as i32 + nx, base_y as i32 + ny, h, w)
618            }
619            _ => (base_x as i32 + lx, base_y as i32 + ly, w, h),
620        }
621    }
622
623    /// Same as [`Self::transform_1d_bar`] but for 2-D codes (QR).
624    #[allow(clippy::too_many_arguments)]
625    fn transform_2d_cell(
626        orientation: char,
627        base_x: u32,
628        base_y: u32,
629        lx: i32,
630        ly: i32,
631        w: u32,
632        h: u32,
633        full_w: u32,
634        full_h: u32,
635    ) -> (i32, i32, u32, u32) {
636        match orientation {
637            'R' => {
638                let nx = full_h as i32 - (ly + h as i32);
639                let ny = lx;
640                (base_x as i32 + nx, base_y as i32 + ny, h, w)
641            }
642            'I' => {
643                let nx = full_w as i32 - (lx + w as i32);
644                let ny = full_h as i32 - (ly + h as i32);
645                (base_x as i32 + nx, base_y as i32 + ny, w, h)
646            }
647            'B' => {
648                let nx = ly;
649                let ny = full_w as i32 - (lx + w as i32);
650                (base_x as i32 + nx, base_y as i32 + ny, h, w)
651            }
652            _ => (base_x as i32 + lx, base_y as i32 + ly, w, h),
653        }
654    }
655
656    // ── 1-D barcode rendering (shared by Code 128 / Code 39) ──────
657
658    #[allow(clippy::too_many_arguments)]
659    fn draw_1d_barcode(
660        &mut self,
661        x: u32,
662        y: u32,
663        orientation: char,
664        height: u32,
665        module_width: u32,
666        data: &str,
667        format: BarcodeFormat,
668        reverse_print: bool,
669        interpretation_line: char,
670        interpretation_line_above: char,
671        hints: Option<EncodeHints>,
672        hints_key: &str,
673    ) -> ZplResult<()> {
674        let bit_matrix = barcode_cache::encode_cached(format, data, hints_key, hints.as_ref())?;
675
676        let mw = max(module_width, 1);
677        let bh = height;
678        let bw = bit_matrix.getWidth() * mw;
679
680        let (full_w, full_h) = match orientation {
681            'R' | 'B' => (bh, bw),
682            _ => (bw, bh),
683        };
684
685        // ── emit bar rectangles ────────────────────────────────────
686        self.save_state();
687        if !reverse_print {
688            self.set_fill_color(0.0, 0.0, 0.0);
689        }
690
691        for gx in 0..bit_matrix.getWidth() {
692            if bit_matrix.get(gx, 0) {
693                let (rx, ry, rw, rh) =
694                    Self::transform_1d_bar(orientation, x, y, (gx * mw) as i32, 0, mw, bh, bw, bh);
695                let px = self.d2pt(rx as f64);
696                let py = self.height_pt - self.d2pt(ry as f64 + rh as f64);
697                let pw = self.d2pt(rw as f64);
698                let ph = self.d2pt(rh as f64);
699                self.emit_nums(&[px, py, pw, ph], "re");
700            }
701        }
702        if reverse_print {
703            // Use the bars as a clip and invert the backdrop inside them.
704            self.emit_op("W");
705            self.emit_op("n");
706            self.fill_inverse_backdrop(x as f64, y as f64, full_w as f64, full_h as f64);
707        } else {
708            self.emit_op("f");
709        }
710        self.restore_state();
711
712        // ── interpretation line ────────────────────────────────────
713        if interpretation_line == 'Y' {
714            self.draw_interpretation_line(x, y, full_w, full_h, data, interpretation_line_above)?;
715        }
716
717        Ok(())
718    }
719
720    #[allow(clippy::too_many_arguments)]
721    fn draw_interpretation_line(
722        &mut self,
723        x: u32,
724        y: u32,
725        full_w: u32,
726        full_h: u32,
727        data: &str,
728        interpretation_line_above: char,
729    ) -> ZplResult<()> {
730        {
731            let font_char = '0';
732            let text_h: u32 = 18;
733            let text_y = if interpretation_line_above == 'Y' {
734                y.saturating_sub(text_h)
735            } else {
736                y + full_h
737            } + 6;
738
739            let text_width = self.get_text_width(data, font_char, Some(text_h), None);
740            let text_x = if full_w > text_width {
741                x + (full_w - text_width) / 2
742            } else {
743                x
744            };
745
746            self.draw_text(
747                text_x,
748                text_y,
749                font_char,
750                Some(text_h),
751                None,
752                'N',
753                data,
754                false,
755                None,
756            )?;
757        }
758
759        Ok(())
760    }
761
762    /// Paints every set cell of a 2-D bit matrix as a filled rectangle,
763    /// scaling each cell to `cell_w` × `cell_h` dots and applying the
764    /// requested orientation.
765    #[allow(clippy::too_many_arguments)]
766    fn fill_matrix_cells(
767        &mut self,
768        x: u32,
769        y: u32,
770        orientation: char,
771        cell_w: u32,
772        cell_h: u32,
773        bit_matrix: &BitMatrix,
774        reverse_print: bool,
775    ) {
776        let bw = bit_matrix.getWidth();
777        let bh = bit_matrix.getHeight();
778        let full_w = bw * cell_w;
779        let full_h = bh * cell_h;
780
781        self.save_state();
782        if !reverse_print {
783            self.set_fill_color(0.0, 0.0, 0.0);
784        }
785
786        for gy in 0..bh {
787            for gx in 0..bw {
788                if bit_matrix.get(gx, gy) {
789                    let (rx, ry, rw, rh) = Self::transform_2d_cell(
790                        orientation,
791                        x,
792                        y,
793                        (gx * cell_w) as i32,
794                        (gy * cell_h) as i32,
795                        cell_w,
796                        cell_h,
797                        full_w,
798                        full_h,
799                    );
800                    let px = self.d2pt(rx as f64);
801                    let py = self.height_pt - self.d2pt(ry as f64 + rh as f64);
802                    let pw = self.d2pt(rw as f64);
803                    let ph = self.d2pt(rh as f64);
804                    self.emit_nums(&[px, py, pw, ph], "re");
805                }
806            }
807        }
808        if reverse_print {
809            self.emit_op("W");
810            self.emit_op("n");
811            let (fw, fh) = match orientation {
812                'R' | 'B' => (full_h, full_w),
813                _ => (full_w, full_h),
814            };
815            self.fill_inverse_backdrop(x as f64, y as f64, fw as f64, fh as f64);
816        } else {
817            self.emit_op("f");
818        }
819        self.restore_state();
820    }
821}
822
823// ─── ZplForgeBackend ────────────────────────────────────────────────────────
824
825impl ZplForgeBackend for PdfNativeBackend {
826    fn setup_page(&mut self, width: f64, height: f64, resolution: f32) {
827        let dpi = if resolution == 0.0 { 203.2 } else { resolution };
828        self.width_dots = width;
829        self.height_dots = height;
830        self.resolution = dpi;
831        self.scale = 72.0 / dpi as f64;
832        self.width_pt = width * self.scale;
833        self.height_pt = height * self.scale;
834    }
835
836    fn setup_font_manager(&mut self, font_manager: &FontManager) {
837        self.font_manager = Some(Arc::new(font_manager.clone()));
838    }
839
840    fn new_page(&mut self) -> ZplResult<()> {
841        self.finished_pages.push(std::mem::take(&mut self.content));
842        self.backdrop_rects.clear();
843        Ok(())
844    }
845
846    // ── text ───────────────────────────────────────────────────────
847
848    fn draw_text(
849        &mut self,
850        x: u32,
851        y: u32,
852        font: char,
853        height: Option<u32>,
854        width: Option<u32>,
855        orientation: char,
856        text: &str,
857        reverse_print: bool,
858        color: Option<String>,
859    ) -> ZplResult<()> {
860        if text.is_empty() {
861            return Ok(());
862        }
863
864        let scale_y_dots = height.unwrap_or(9) as f32;
865        let scale_x_dots = width.unwrap_or(scale_y_dots as u32) as f32;
866        let px_scale = PxScale {
867            x: scale_x_dots,
868            y: scale_y_dots,
869        };
870
871        // Compute ascent in a scoped borrow so `font_arc` is dropped before the mutable insert.
872        let ascent_dots = {
873            let font_arc = self.get_font_arc(font)?;
874            font_arc.as_scaled(px_scale).ascent()
875        } as f64;
876
877        self.used_fonts.insert(font);
878
879        let scale_x_pt = self.d2pt(scale_x_dots as f64);
880        let scale_y_pt = self.d2pt(scale_y_dots as f64);
881        let h_dots = scale_y_dots as f64;
882        let x = x as f64;
883        let y = y as f64;
884
885        // Text width anchors 'I'/'B' rotations and sizes the reverse bbox.
886        let tw_dots = if reverse_print || orientation == 'I' || orientation == 'B' {
887            self.get_text_width(text, font, height, width) as f64
888        } else {
889            0.0
890        };
891
892        // Text matrix [a b c d tx ty]: scale plus the ^A rotation, with
893        // (x, y) anchoring the top-left corner of the rotated cell.
894        let tm = match orientation {
895            'R' => [
896                0.0,
897                -scale_x_pt,
898                scale_y_pt,
899                0.0,
900                self.x_pt(x + h_dots - ascent_dots),
901                self.height_pt - y * self.scale,
902            ],
903            'I' => [
904                -scale_x_pt,
905                0.0,
906                0.0,
907                -scale_y_pt,
908                self.x_pt(x + tw_dots),
909                self.height_pt - (y + h_dots - ascent_dots) * self.scale,
910            ],
911            'B' => [
912                0.0,
913                scale_x_pt,
914                -scale_y_pt,
915                0.0,
916                self.x_pt(x + ascent_dots),
917                self.height_pt - (y + tw_dots) * self.scale,
918            ],
919            _ => [
920                scale_x_pt,
921                0.0,
922                0.0,
923                scale_y_pt,
924                self.x_pt(x),
925                self.height_pt - (y + ascent_dots) * self.scale,
926            ],
927        };
928
929        self.save_state();
930        if !reverse_print {
931            let (r, g, b) = Self::parse_hex_color_f64(&color);
932            self.set_fill_color(r, g, b);
933        }
934
935        self.emit_op("BT");
936        if reverse_print {
937            // Text rendering mode 7: glyph outlines become the clipping path.
938            self.emit_nums(&[7.0], "Tr");
939        }
940        self.emit_nums(&tm, "Tm");
941        let font_resource_name = format!("F_{}", font);
942        self.emit_name_op(&format!("{} 1", font_resource_name), "Tf");
943        self.emit_tj(text);
944        self.emit_op("ET");
945
946        if reverse_print {
947            let (bw_dots, bh_dots) = match orientation {
948                'R' | 'B' => (h_dots, tw_dots),
949                _ => (tw_dots, h_dots),
950            };
951            self.fill_inverse_backdrop(x, y, bw_dots, bh_dots);
952        }
953        self.restore_state();
954
955        Ok(())
956    }
957
958    // ── graphic box (rounded rectangle) ────────────────────────────
959
960    fn draw_graphic_box(
961        &mut self,
962        x: u32,
963        y: u32,
964        width: u32,
965        height: u32,
966        thickness: u32,
967        color: char,
968        custom_color: Option<String>,
969        rounding: u32,
970        reverse_print: bool,
971    ) -> ZplResult<()> {
972        let w = max(width, 1) as f64;
973        let h = max(height, 1) as f64;
974        let t = thickness as f64;
975        let r_dots = rounding as f64 * 8.0;
976
977        let (draw_color, clear_color) = Self::resolve_colors(color, &custom_color);
978
979        let bx = self.x_pt(x as f64);
980        let by = self.y_pt_bottom(y as f64, h);
981        let bw = self.d2pt(w);
982        let bh = self.d2pt(h);
983        let br = self.d2pt(r_dots);
984
985        let has_inner = t * 2.0 < w && t * 2.0 < h;
986
987        if reverse_print {
988            // Clip to the box (solid) or its border ring (even-odd) and
989            // repaint the inverse of the backdrop inside it.
990            self.save_state();
991            self.push_rounded_rect_path(bx, by, bw, bh, br);
992            if has_inner {
993                let tp = self.d2pt(t);
994                let inner_r = self.d2pt((r_dots - t).max(0.0));
995                self.push_rounded_rect_path(
996                    bx + tp,
997                    by + tp,
998                    bw - tp * 2.0,
999                    bh - tp * 2.0,
1000                    inner_r,
1001                );
1002                self.emit_op("W*");
1003            } else {
1004                self.emit_op("W");
1005            }
1006            self.emit_op("n");
1007            self.fill_inverse_backdrop(x as f64, y as f64, w, h);
1008            self.restore_state();
1009        } else {
1010            self.save_state();
1011            let (r, g, b) = draw_color;
1012            self.set_fill_color(r, g, b);
1013            self.push_rounded_rect_path(bx, by, bw, bh, br);
1014            self.emit_op("f");
1015            self.track_backdrop_rect(x as f64, y as f64, w, h, draw_color);
1016
1017            if has_inner {
1018                let (cr, cg, cb) = clear_color;
1019                self.set_fill_color(cr, cg, cb);
1020                let tp = self.d2pt(t);
1021                let inner_r = self.d2pt((r_dots - t).max(0.0));
1022                self.push_rounded_rect_path(
1023                    bx + tp,
1024                    by + tp,
1025                    bw - tp * 2.0,
1026                    bh - tp * 2.0,
1027                    inner_r,
1028                );
1029                self.emit_op("f");
1030                self.track_backdrop_rect(
1031                    x as f64 + t,
1032                    y as f64 + t,
1033                    w - t * 2.0,
1034                    h - t * 2.0,
1035                    clear_color,
1036                );
1037            }
1038            self.restore_state();
1039        }
1040
1041        Ok(())
1042    }
1043
1044    // ── graphic circle ─────────────────────────────────────────────
1045
1046    fn draw_graphic_circle(
1047        &mut self,
1048        x: u32,
1049        y: u32,
1050        radius: u32,
1051        thickness: u32,
1052        _color: char,
1053        custom_color: Option<String>,
1054        reverse_print: bool,
1055    ) -> ZplResult<()> {
1056        let (draw_color, _) = Self::resolve_colors('B', &custom_color);
1057
1058        let r_pt = self.d2pt(radius as f64);
1059        // ZPL (x,y) = top-left of bounding box → centre
1060        let cx_pt = self.x_pt(x as f64) + r_pt;
1061        let cy_pt = self.height_pt - (y as f64 + radius as f64) * self.scale;
1062
1063        if reverse_print {
1064            self.save_state();
1065            self.push_ellipse_path(cx_pt, cy_pt, r_pt, r_pt);
1066            if radius > thickness {
1067                let inner_r = self.d2pt((radius - thickness) as f64);
1068                self.push_ellipse_path(cx_pt, cy_pt, inner_r, inner_r);
1069                self.emit_op("W*");
1070            } else {
1071                self.emit_op("W");
1072            }
1073            self.emit_op("n");
1074            self.fill_inverse_backdrop(
1075                x as f64,
1076                y as f64,
1077                radius as f64 * 2.0,
1078                radius as f64 * 2.0,
1079            );
1080            self.restore_state();
1081        } else {
1082            self.save_state();
1083            let (r, g, b) = draw_color;
1084            self.set_fill_color(r, g, b);
1085            self.push_ellipse_path(cx_pt, cy_pt, r_pt, r_pt);
1086            self.emit_op("f");
1087
1088            if radius > thickness {
1089                self.set_fill_color(1.0, 1.0, 1.0);
1090                let inner_r = self.d2pt((radius - thickness) as f64);
1091                self.push_ellipse_path(cx_pt, cy_pt, inner_r, inner_r);
1092                self.emit_op("f");
1093            }
1094            self.restore_state();
1095        }
1096
1097        Ok(())
1098    }
1099
1100    // ── graphic ellipse ────────────────────────────────────────────
1101
1102    fn draw_graphic_ellipse(
1103        &mut self,
1104        x: u32,
1105        y: u32,
1106        width: u32,
1107        height: u32,
1108        thickness: u32,
1109        _color: char,
1110        custom_color: Option<String>,
1111        reverse_print: bool,
1112    ) -> ZplResult<()> {
1113        let (draw_color, _) = Self::resolve_colors('B', &custom_color);
1114
1115        let rx_pt = self.d2pt(width as f64 / 2.0);
1116        let ry_pt = self.d2pt(height as f64 / 2.0);
1117        let cx_pt = self.x_pt(x as f64) + rx_pt;
1118        let cy_pt = self.height_pt - (y as f64 + height as f64 / 2.0) * self.scale;
1119
1120        let t = thickness as f64;
1121
1122        if reverse_print {
1123            self.save_state();
1124            self.push_ellipse_path(cx_pt, cy_pt, rx_pt, ry_pt);
1125            if (width as f64 / 2.0) > t && (height as f64 / 2.0) > t {
1126                let irx = self.d2pt(width as f64 / 2.0 - t);
1127                let iry = self.d2pt(height as f64 / 2.0 - t);
1128                self.push_ellipse_path(cx_pt, cy_pt, irx, iry);
1129                self.emit_op("W*");
1130            } else {
1131                self.emit_op("W");
1132            }
1133            self.emit_op("n");
1134            self.fill_inverse_backdrop(x as f64, y as f64, width as f64, height as f64);
1135            self.restore_state();
1136        } else {
1137            self.save_state();
1138            let (r, g, b) = draw_color;
1139            self.set_fill_color(r, g, b);
1140            self.push_ellipse_path(cx_pt, cy_pt, rx_pt, ry_pt);
1141            self.emit_op("f");
1142
1143            if (width as f64 / 2.0) > t && (height as f64 / 2.0) > t {
1144                self.set_fill_color(1.0, 1.0, 1.0);
1145                let irx = self.d2pt(width as f64 / 2.0 - t);
1146                let iry = self.d2pt(height as f64 / 2.0 - t);
1147                self.push_ellipse_path(cx_pt, cy_pt, irx, iry);
1148                self.emit_op("f");
1149            }
1150            self.restore_state();
1151        }
1152
1153        Ok(())
1154    }
1155
1156    // ── graphic field (1-bit bitmap) ───────────────────────────────
1157
1158    fn draw_graphic_field(
1159        &mut self,
1160        x: u32,
1161        y: u32,
1162        width: u32,
1163        height: u32,
1164        data: &[u8],
1165        reverse_print: bool,
1166    ) -> ZplResult<()> {
1167        if width == 0 || height == 0 {
1168            return Ok(());
1169        }
1170
1171        // ZPL ^GF rows are already byte-padded (ceil(width/8) bytes per row),
1172        // exactly the layout a 1-bit PDF image expects. Pad or truncate to the
1173        // full bitmap size; padding bytes are 0 (unpainted with Decode [1 0]).
1174        let row_bytes = width.div_ceil(8) as usize;
1175        let total_bytes = row_bytes * height as usize;
1176        let mut bits = data.to_vec();
1177        bits.resize(total_bytes, 0x00);
1178
1179        self.embed_mask_image(x as f64, y as f64, width, height, bits, reverse_print);
1180        Ok(())
1181    }
1182
1183    // ── custom colour image (base64) ───────────────────────────────
1184
1185    fn draw_graphic_image_custom(
1186        &mut self,
1187        x: u32,
1188        y: u32,
1189        width: u32,
1190        height: u32,
1191        data: &str,
1192    ) -> ZplResult<()> {
1193        let image_data = general_purpose::STANDARD
1194            .decode(data.trim())
1195            .map_err(|e| ZplError::ImageError(format!("Failed to decode base64: {}", e)))?;
1196
1197        let img = image::load_from_memory(&image_data)
1198            .map_err(|e| ZplError::ImageError(format!("Failed to load image: {}", e)))?
1199            .to_rgb8();
1200
1201        let (orig_w, orig_h) = img.dimensions();
1202        let (target_w, target_h) = match (width, height) {
1203            (0, 0) => (orig_w, orig_h),
1204            (w, 0) => {
1205                let h = (orig_h as f32 * (w as f32 / orig_w as f32)).round() as u32;
1206                (w, h)
1207            }
1208            (0, h) => {
1209                let w = (orig_w as f32 * (h as f32 / orig_h as f32)).round() as u32;
1210                (w, h)
1211            }
1212            (w, h) => (w, h),
1213        };
1214
1215        let final_img = if target_w != orig_w || target_h != orig_h {
1216            image::imageops::resize(
1217                &img,
1218                target_w,
1219                target_h,
1220                image::imageops::FilterType::Lanczos3,
1221            )
1222        } else {
1223            img
1224        };
1225
1226        let rgb_data = final_img.into_raw();
1227        self.embed_rgb_image(x as f64, y as f64, target_w, target_h, rgb_data);
1228        Ok(())
1229    }
1230
1231    // ── Code 128 barcode ───────────────────────────────────────────
1232
1233    fn draw_code128(
1234        &mut self,
1235        x: u32,
1236        y: u32,
1237        orientation: char,
1238        height: u32,
1239        module_width: u32,
1240        interpretation_line: char,
1241        interpretation_line_above: char,
1242        _check_digit: char,
1243        _mode: char,
1244        data: &str,
1245        reverse_print: bool,
1246    ) -> ZplResult<()> {
1247        let (clean_data, hint_val) = if let Some(stripped) = data.strip_prefix(">:") {
1248            (stripped, Some("B"))
1249        } else if let Some(stripped) = data.strip_prefix(">;") {
1250            (stripped, Some("C"))
1251        } else if let Some(stripped) = data.strip_prefix(">9") {
1252            (stripped, Some("A"))
1253        } else {
1254            (data, Some("B")) // Standard default is Code Set B
1255        };
1256
1257        let hints = hint_val.map(|v| {
1258            let mut h = HashMap::new();
1259            h.insert(
1260                EncodeHintType::FORCE_CODE_SET,
1261                EncodeHintValue::ForceCodeSet(v.to_string()),
1262            );
1263            EncodeHints::from(h)
1264        });
1265
1266        self.draw_1d_barcode(
1267            x,
1268            y,
1269            orientation,
1270            height,
1271            module_width,
1272            clean_data,
1273            BarcodeFormat::CODE_128,
1274            reverse_print,
1275            interpretation_line,
1276            interpretation_line_above,
1277            hints,
1278            hint_val.unwrap_or(""),
1279        )
1280    }
1281
1282    // ── QR code ────────────────────────────────────────────────────
1283
1284    fn draw_qr_code(
1285        &mut self,
1286        x: u32,
1287        y: u32,
1288        orientation: char,
1289        _model: u32,
1290        magnification: u32,
1291        error_correction: char,
1292        _mask: u32,
1293        data: &str,
1294        reverse_print: bool,
1295    ) -> ZplResult<()> {
1296        let level = match error_correction {
1297            'L' => "L",
1298            'M' => "M",
1299            'Q' => "Q",
1300            'H' => "H",
1301            _ => "M",
1302        };
1303
1304        let mut hints = HashMap::new();
1305        hints.insert(
1306            EncodeHintType::ERROR_CORRECTION,
1307            EncodeHintValue::ErrorCorrection(level.to_string()),
1308        );
1309        hints.insert(
1310            EncodeHintType::MARGIN,
1311            EncodeHintValue::Margin("0".to_owned()),
1312        );
1313        let hints: EncodeHints = hints.into();
1314
1315        let bit_matrix = barcode_cache::encode_cached(
1316            BarcodeFormat::QR_CODE,
1317            data,
1318            &format!("ec:{}", level),
1319            Some(&hints),
1320        )?;
1321
1322        let mag = max(magnification, 1);
1323        self.fill_matrix_cells(x, y, orientation, mag, mag, &bit_matrix, reverse_print);
1324        Ok(())
1325    }
1326
1327    // ── Data Matrix barcode ────────────────────────────────────────
1328
1329    fn draw_datamatrix(
1330        &mut self,
1331        x: u32,
1332        y: u32,
1333        orientation: char,
1334        module_size: u32,
1335        data: &str,
1336        reverse_print: bool,
1337    ) -> ZplResult<()> {
1338        let bit_matrix = barcode_cache::encode_cached(BarcodeFormat::DATA_MATRIX, data, "", None)?;
1339
1340        let m = max(module_size, 1);
1341        self.fill_matrix_cells(x, y, orientation, m, m, &bit_matrix, reverse_print);
1342        Ok(())
1343    }
1344
1345    // ── PDF417 barcode ─────────────────────────────────────────────
1346
1347    fn draw_pdf417(
1348        &mut self,
1349        x: u32,
1350        y: u32,
1351        orientation: char,
1352        row_height: u32,
1353        module_width: u32,
1354        security_level: u32,
1355        data: &str,
1356        reverse_print: bool,
1357    ) -> ZplResult<()> {
1358        let mut hints = HashMap::new();
1359        hints.insert(
1360            EncodeHintType::ERROR_CORRECTION,
1361            EncodeHintValue::ErrorCorrection(security_level.min(8).to_string()),
1362        );
1363        hints.insert(
1364            EncodeHintType::MARGIN,
1365            EncodeHintValue::Margin("0".to_owned()),
1366        );
1367        let hints: EncodeHints = hints.into();
1368
1369        let bit_matrix = barcode_cache::encode_cached(
1370            BarcodeFormat::PDF_417,
1371            data,
1372            &format!("ec:{}", security_level.min(8)),
1373            Some(&hints),
1374        )?;
1375
1376        let cw = max(module_width, 1);
1377        let ch = max(row_height, 1);
1378        self.fill_matrix_cells(x, y, orientation, cw, ch, &bit_matrix, reverse_print);
1379        Ok(())
1380    }
1381
1382    // ── Code 39 barcode ────────────────────────────────────────────
1383
1384    fn draw_code39(
1385        &mut self,
1386        x: u32,
1387        y: u32,
1388        orientation: char,
1389        _check_digit: char,
1390        height: u32,
1391        module_width: u32,
1392        interpretation_line: char,
1393        interpretation_line_above: char,
1394        data: &str,
1395        reverse_print: bool,
1396    ) -> ZplResult<()> {
1397        self.draw_1d_barcode(
1398            x,
1399            y,
1400            orientation,
1401            height,
1402            module_width,
1403            data,
1404            BarcodeFormat::CODE_39,
1405            reverse_print,
1406            interpretation_line,
1407            interpretation_line_above,
1408            None,
1409            "",
1410        )
1411    }
1412
1413    // ── generic 1-D barcodes (EAN-13, UPC-A, ITF, Code 93) ────────
1414
1415    fn draw_barcode_1d(
1416        &mut self,
1417        kind: Barcode1DKind,
1418        x: u32,
1419        y: u32,
1420        orientation: char,
1421        height: u32,
1422        module_width: u32,
1423        interpretation_line: char,
1424        interpretation_line_above: char,
1425        data: &str,
1426        reverse_print: bool,
1427    ) -> ZplResult<()> {
1428        self.draw_1d_barcode(
1429            x,
1430            y,
1431            orientation,
1432            height,
1433            module_width,
1434            data,
1435            barcode_1d_format(kind),
1436            reverse_print,
1437            interpretation_line,
1438            interpretation_line_above,
1439            None,
1440            "",
1441        )
1442    }
1443
1444    // ── diagonal line (^GD) ────────────────────────────────────────
1445
1446    fn draw_graphic_diagonal(
1447        &mut self,
1448        x: u32,
1449        y: u32,
1450        width: u32,
1451        height: u32,
1452        thickness: u32,
1453        color: char,
1454        custom_color: Option<String>,
1455        diagonal_orientation: char,
1456        reverse_print: bool,
1457    ) -> ZplResult<()> {
1458        let (draw_color, _) = Self::resolve_colors(color, &custom_color);
1459
1460        let w = max(width, 1) as f64;
1461        let h = max(height, 1) as f64;
1462        let t = (max(thickness, 1) as f64).min(w);
1463        let x = x as f64;
1464        let y = y as f64;
1465
1466        // Filled parallelogram with horizontal thickness `t`.
1467        let pts: [(f64, f64); 4] = if diagonal_orientation == 'L' {
1468            // '\' top-left → bottom-right
1469            [(x, y), (x + t, y), (x + w, y + h), (x + w - t, y + h)]
1470        } else {
1471            // '/' bottom-left → top-right
1472            [(x, y + h), (x + t, y + h), (x + w, y), (x + w - t, y)]
1473        };
1474
1475        self.save_state();
1476        if !reverse_print {
1477            let (r, g, b) = draw_color;
1478            self.set_fill_color(r, g, b);
1479        }
1480
1481        for (i, (dx, dy)) in pts.iter().enumerate() {
1482            let px = self.x_pt(*dx);
1483            let py = self.height_pt - dy * self.scale;
1484            self.emit_nums(&[px, py], if i == 0 { "m" } else { "l" });
1485        }
1486        self.emit_op("h");
1487        if reverse_print {
1488            self.emit_op("W");
1489            self.emit_op("n");
1490            self.fill_inverse_backdrop(x, y, w, h);
1491        } else {
1492            self.emit_op("f");
1493        }
1494        self.restore_state();
1495
1496        Ok(())
1497    }
1498
1499    // ── finalize ───────────────────────────────────────────────────
1500
1501    fn finalize(&mut self) -> ZplResult<Vec<u8>> {
1502        let mut doc = Document::with_version("1.5");
1503        let pages_id = doc.new_object_id();
1504
1505        // ── embed fonts ────────────────────────────────────────────
1506        //
1507        // Font objects are built manually instead of using `lopdf::Document::
1508        // add_font`, which omits /Widths and /ToUnicode and stores descriptor
1509        // metrics in raw font units. Here every metric is normalized to the
1510        // 1000/em glyph space and a ToUnicode CMap makes text extraction
1511        // (copy/paste, search) work for the full WinAnsi range.
1512        let default_font_bytes: &[u8] = include_bytes!("../assets/IosevkaTermSlab-Regular.ttf");
1513        let mut font_dict = lopdf::Dictionary::new();
1514        // Dedup: multiple ZPL identifiers often map to the same font.
1515        let mut embedded_fonts: HashMap<String, lopdf::ObjectId> = HashMap::new();
1516        let tounicode_id = doc.add_object(Stream::new(dictionary! {}, build_tounicode_cmap()));
1517
1518        for font_char in &self.used_fonts {
1519            let font_key = font_char.to_string();
1520            let resource_name = format!("F_{}", font_char);
1521
1522            let actual_name = self
1523                .font_manager
1524                .as_ref()
1525                .and_then(|fm| fm.get_font_name(&font_key).map(|s| s.to_string()))
1526                .unwrap_or_else(|| "Iosevka Term Slab".to_string());
1527
1528            if let Some(font_id) = embedded_fonts.get(&actual_name) {
1529                font_dict.set(resource_name.as_str(), *font_id);
1530                continue;
1531            }
1532
1533            let raw_bytes = self
1534                .font_manager
1535                .as_ref()
1536                .and_then(|fm| fm.get_font_bytes(&font_key))
1537                .unwrap_or(default_font_bytes);
1538
1539            let face = FontArc::try_from_vec(raw_bytes.to_vec())
1540                .map_err(|e| ZplError::FontError(format!("Invalid font data: {}", e)))?;
1541            let upem = face.units_per_em().unwrap_or(1000.0) as f64;
1542            let to_glyph_space = |v: f64| (v * 1000.0 / upem).round() as i64;
1543
1544            // /Widths for the WinAnsi code range 32..=255.
1545            let widths: Vec<Object> = (0x20..=0xFFu32)
1546                .map(|code| {
1547                    let w = winansi_to_char(code as u8)
1548                        .map(|ch| to_glyph_space(face.h_advance_unscaled(face.glyph_id(ch)) as f64))
1549                        .unwrap_or(0);
1550                    w.into()
1551                })
1552                .collect();
1553
1554            // Bounding box and style metrics via ttf-parser (lopdf::FontData).
1555            let fd = FontData::new(raw_bytes, actual_name.clone());
1556
1557            let font_stream = Stream::new(
1558                dictionary! { "Length1" => raw_bytes.len() as i64 },
1559                raw_bytes.to_vec(),
1560            );
1561            let font_file_id = doc.add_object(font_stream);
1562
1563            let descriptor_id = doc.add_object(dictionary! {
1564                "Type" => "FontDescriptor",
1565                "FontName" => Object::Name(actual_name.clone().into_bytes()),
1566                "Flags" => 32_i64,
1567                "FontBBox" => vec![
1568                    to_glyph_space(fd.font_bbox.0 as f64).into(),
1569                    to_glyph_space(fd.font_bbox.1 as f64).into(),
1570                    to_glyph_space(fd.font_bbox.2 as f64).into(),
1571                    to_glyph_space(fd.font_bbox.3 as f64).into(),
1572                ],
1573                "ItalicAngle" => fd.italic_angle,
1574                "Ascent" => to_glyph_space(fd.ascent as f64),
1575                "Descent" => to_glyph_space(fd.descent as f64),
1576                "CapHeight" => to_glyph_space(fd.cap_height as f64),
1577                "StemV" => 80_i64,
1578                "FontFile2" => font_file_id,
1579            });
1580
1581            let font_id = doc.add_object(dictionary! {
1582                "Type" => "Font",
1583                "Subtype" => "TrueType",
1584                "BaseFont" => Object::Name(actual_name.clone().into_bytes()),
1585                "FirstChar" => 32_i64,
1586                "LastChar" => 255_i64,
1587                "Widths" => widths,
1588                "FontDescriptor" => descriptor_id,
1589                "Encoding" => "WinAnsiEncoding",
1590                "ToUnicode" => tounicode_id,
1591            });
1592
1593            font_dict.set(resource_name.as_str(), font_id);
1594            embedded_fonts.insert(actual_name, font_id);
1595        }
1596
1597        // ── XObject images ─────────────────────────────────────────
1598        let mut xobject_dict = lopdf::Dictionary::new();
1599        for img in &self.images {
1600            let mut encoder = ZlibEncoder::new(Vec::new(), self.compression);
1601            encoder
1602                .write_all(&img.data)
1603                .map_err(|e| ZplError::BackendError(e.to_string()))?;
1604            let compressed = encoder
1605                .finish()
1606                .map_err(|e| ZplError::BackendError(e.to_string()))?;
1607
1608            let dict = if img.is_mask {
1609                // Stencil mask: sample 1 paints with the current fill colour
1610                // (Decode [1 0]), sample 0 leaves the page untouched.
1611                dictionary! {
1612                    "Type" => "XObject",
1613                    "Subtype" => "Image",
1614                    "Width" => img.width as i64,
1615                    "Height" => img.height as i64,
1616                    "ImageMask" => true,
1617                    "BitsPerComponent" => 1,
1618                    "Decode" => vec![0.into(), 1.into()],
1619                    "Filter" => "FlateDecode",
1620                }
1621            } else {
1622                dictionary! {
1623                    "Type" => "XObject",
1624                    "Subtype" => "Image",
1625                    "Width" => img.width as i64,
1626                    "Height" => img.height as i64,
1627                    "ColorSpace" => "DeviceRGB",
1628                    "BitsPerComponent" => 8,
1629                    "Filter" => "FlateDecode",
1630                }
1631            };
1632            let img_stream = Stream::new(dict, compressed);
1633            let img_id = doc.add_object(img_stream);
1634            xobject_dict.set(img.name.as_str(), img_id);
1635        }
1636
1637        // ── resources ──────────────────────────────────────────────
1638        let resources_id = doc.add_object(dictionary! {
1639            "Font" => lopdf::Object::Dictionary(font_dict),
1640            "XObject" => lopdf::Object::Dictionary(xobject_dict),
1641        });
1642
1643        // ── pages (one content stream each, shared resources) ──────
1644        let mut page_contents = std::mem::take(&mut self.finished_pages);
1645        page_contents.push(std::mem::take(&mut self.content));
1646
1647        let mut kids: Vec<Object> = Vec::with_capacity(page_contents.len());
1648        for content_bytes in page_contents {
1649            let content_id = doc.add_object(Stream::new(dictionary! {}, content_bytes));
1650            let page_id = doc.add_object(dictionary! {
1651                "Type" => "Page",
1652                "Parent" => pages_id,
1653                "MediaBox" => vec![
1654                    0.into(),
1655                    0.into(),
1656                    Object::Real(self.width_pt as f32),
1657                    Object::Real(self.height_pt as f32),
1658                ],
1659                "Contents" => content_id,
1660                "Resources" => resources_id,
1661            });
1662            kids.push(page_id.into());
1663        }
1664
1665        // ── pages tree ─────────────────────────────────────────────
1666        let pages_dict = dictionary! {
1667            "Type" => "Pages",
1668            "Count" => kids.len() as i64,
1669            "Kids" => kids,
1670        };
1671        doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
1672
1673        // ── catalogue ──────────────────────────────────────────────
1674        let catalog_id = doc.add_object(dictionary! {
1675            "Type" => "Catalog",
1676            "Pages" => pages_id,
1677        });
1678        doc.trailer.set("Root", catalog_id);
1679
1680        // ── document info ──────────────────────────────────────────
1681        let mut info = lopdf::Dictionary::new();
1682        info.set(
1683            "Producer",
1684            Object::string_literal(concat!("zpl-forge ", env!("CARGO_PKG_VERSION"))),
1685        );
1686        if let Some(title) = &self.title {
1687            info.set("Title", Object::string_literal(title.as_str()));
1688        }
1689        let info_id = doc.add_object(Object::Dictionary(info));
1690        doc.trailer.set("Info", info_id);
1691
1692        doc.compress();
1693
1694        // ── serialize ──────────────────────────────────────────────
1695        let mut buf = std::io::BufWriter::new(Vec::new());
1696        doc.save_to(&mut buf)
1697            .map_err(|e| ZplError::BackendError(format!("Failed to save PDF: {}", e)))?;
1698        buf.into_inner()
1699            .map_err(|e| ZplError::BackendError(format!("Failed to flush: {}", e)))
1700    }
1701}