1use std::cmp::max;
7use std::collections::{HashMap, HashSet};
8use std::io::Write;
9use std::sync::Arc;
10
11use ab_glyph::{Font, FontArc};
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
23const KAPPA: f64 = 0.5522847498;
25
26const 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
40fn char_to_winansi(c: char) -> Option<u8> {
42 let cp = c as u32;
43 match cp {
44 0x20..=0x7E => Some(cp as u8),
45 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
54fn 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
67fn 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
90struct ImageXObject {
94 name: String,
95 data: Vec<u8>,
96 width: u32,
97 height: u32,
98 is_mask: bool,
100}
101
102pub struct PdfNativeBackend {
111 width_dots: f64,
112 height_dots: f64,
113 width_pt: f64,
114 height_pt: f64,
115 resolution: f32,
116 scale: f64,
118 content: Vec<u8>,
120 finished_pages: Vec<Vec<u8>>,
122 font_manager: Option<Arc<FontManager>>,
123 images: Vec<ImageXObject>,
124 image_counter: usize,
125 used_fonts: HashSet<char>,
127 compression: Compression,
128 title: Option<String>,
130 #[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
143impl PdfNativeBackend {
146 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 pub fn with_compression(mut self, compression: Compression) -> Self {
169 self.compression = compression;
170 self
171 }
172
173 pub fn with_title(mut self, title: impl Into<String>) -> Self {
175 self.title = Some(title.into());
176 self
177 }
178}
179
180impl PdfNativeBackend {
183 #[inline]
187 fn d2pt(&self, dots: f64) -> f64 {
188 dots * self.scale
189 }
190
191 #[inline]
193 fn x_pt(&self, x: f64) -> f64 {
194 x * self.scale
195 }
196
197 #[inline]
200 fn y_pt_bottom(&self, y: f64, h: f64) -> f64 {
201 self.height_pt - (y + h) * self.scale
202 }
203
204 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 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 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 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 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 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 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 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 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 fn fill_inverse_backdrop(&mut self, ex: f64, ey: f64, ew: f64, eh: f64) {
377 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 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 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 self.emit_nums(&[x + r, y], "m");
418 self.emit_nums(&[x + w - r, y], "l");
419 self.emit_nums(&[x + w - r + kr, y, x + w, y + r - kr, x + w, y + r], "c");
421 self.emit_nums(&[x + w, y + h - r], "l");
423 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 self.emit_nums(&[x + r, y + h], "l");
437 self.emit_nums(&[x + r - kr, y + h, x, y + h - r + kr, x, y + h - r], "c");
439 self.emit_nums(&[x, y + r], "l");
441 self.emit_nums(&[x, y + r - kr, x + r - kr, y, x + r, y], "c");
443 self.emit_op("h");
444 }
445
446 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 self.emit_nums(&[cx + rx, cy], "m");
453 self.emit_nums(&[cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry], "c");
455 self.emit_nums(&[cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy], "c");
457 self.emit_nums(&[cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry], "c");
459 self.emit_nums(&[cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy], "c");
461 self.emit_op("h");
462 }
463
464 fn get_text_width(
467 &self,
468 text: &str,
469 font_char: char,
470 height: Option<u32>,
471 width: Option<u32>,
472 ) -> u32 {
473 match self.font_manager.as_ref() {
474 Some(fm) => fm.measure_text(font_char, height, width, text),
475 None => 0,
476 }
477 }
478
479 fn embed_rgb_image(
484 &mut self,
485 x_dots: f64,
486 y_dots: f64,
487 img_w: u32,
488 img_h: u32,
489 rgb_data: Vec<u8>,
490 ) {
491 let name = format!("Im{}", self.image_counter);
492 self.image_counter += 1;
493
494 let px = self.x_pt(x_dots);
495 let py = self.y_pt_bottom(y_dots, img_h as f64);
496 let pw = self.d2pt(img_w as f64);
497 let ph = self.d2pt(img_h as f64);
498
499 self.save_state();
500 self.emit_nums(&[pw, 0.0, 0.0, ph, px, py], "cm");
501 self.emit_name_op(&name, "Do");
502 self.restore_state();
503
504 self.images.push(ImageXObject {
505 name,
506 data: rgb_data,
507 width: img_w,
508 height: img_h,
509 is_mask: false,
510 });
511 }
512
513 fn embed_mask_image(
517 &mut self,
518 x_dots: f64,
519 y_dots: f64,
520 img_w: u32,
521 img_h: u32,
522 bits: Vec<u8>,
523 reverse_print: bool,
524 ) {
525 let name = format!("Im{}", self.image_counter);
526 self.image_counter += 1;
527
528 let px = self.x_pt(x_dots);
529 let py = self.y_pt_bottom(y_dots, img_h as f64);
530 let pw = self.d2pt(img_w as f64);
531 let ph = self.d2pt(img_h as f64);
532
533 self.save_state();
534 if reverse_print {
535 let (br, bg, bb) =
539 self.backdrop_color_at(x_dots + img_w as f64 / 2.0, y_dots + img_h as f64 / 2.0);
540 self.set_fill_color(1.0 - br, 1.0 - bg, 1.0 - bb);
541 } else {
542 self.set_fill_color(0.0, 0.0, 0.0);
543 }
544 self.emit_nums(&[pw, 0.0, 0.0, ph, px, py], "cm");
545 self.emit_name_op(&name, "Do");
546 self.restore_state();
547
548 self.images.push(ImageXObject {
549 name,
550 data: bits,
551 width: img_w,
552 height: img_h,
553 is_mask: true,
554 });
555 }
556
557 #[allow(clippy::too_many_arguments)]
564 fn transform_1d_bar(
565 orientation: char,
566 base_x: u32,
567 base_y: u32,
568 lx: i32,
569 ly: i32,
570 w: u32,
571 h: u32,
572 bw: u32,
573 bh: u32,
574 ) -> (i32, i32, u32, u32) {
575 match orientation {
576 'R' => {
577 let nx = bh as i32 - (ly + h as i32);
578 let ny = lx;
579 (base_x as i32 + nx, base_y as i32 + ny, h, w)
580 }
581 'I' => {
582 let nx = bw as i32 - (lx + w as i32);
583 let ny = bh as i32 - (ly + h as i32);
584 (base_x as i32 + nx, base_y as i32 + ny, w, h)
585 }
586 'B' => {
587 let nx = ly;
588 let ny = bw as i32 - (lx + w as i32);
589 (base_x as i32 + nx, base_y as i32 + ny, h, w)
590 }
591 _ => (base_x as i32 + lx, base_y as i32 + ly, w, h),
592 }
593 }
594
595 #[allow(clippy::too_many_arguments)]
597 fn transform_2d_cell(
598 orientation: char,
599 base_x: u32,
600 base_y: u32,
601 lx: i32,
602 ly: i32,
603 w: u32,
604 h: u32,
605 full_w: u32,
606 full_h: u32,
607 ) -> (i32, i32, u32, u32) {
608 match orientation {
609 'R' => {
610 let nx = full_h as i32 - (ly + h as i32);
611 let ny = lx;
612 (base_x as i32 + nx, base_y as i32 + ny, h, w)
613 }
614 'I' => {
615 let nx = full_w as i32 - (lx + w as i32);
616 let ny = full_h as i32 - (ly + h as i32);
617 (base_x as i32 + nx, base_y as i32 + ny, w, h)
618 }
619 'B' => {
620 let nx = ly;
621 let ny = full_w as i32 - (lx + w as i32);
622 (base_x as i32 + nx, base_y as i32 + ny, h, w)
623 }
624 _ => (base_x as i32 + lx, base_y as i32 + ly, w, h),
625 }
626 }
627
628 #[allow(clippy::too_many_arguments)]
631 fn draw_1d_barcode(
632 &mut self,
633 x: u32,
634 y: u32,
635 orientation: char,
636 height: u32,
637 module_width: u32,
638 data: &str,
639 format: BarcodeFormat,
640 reverse_print: bool,
641 interpretation_line: char,
642 interpretation_line_above: char,
643 hints: Option<EncodeHints>,
644 hints_key: &str,
645 ) -> ZplResult<()> {
646 let bit_matrix = barcode_cache::encode_cached(format, data, hints_key, hints.as_ref())?;
647
648 let mw = max(module_width, 1);
649 let bh = height;
650 let bw = bit_matrix.getWidth() * mw;
651
652 let (full_w, full_h) = match orientation {
653 'R' | 'B' => (bh, bw),
654 _ => (bw, bh),
655 };
656
657 self.save_state();
659 if !reverse_print {
660 self.set_fill_color(0.0, 0.0, 0.0);
661 }
662
663 for gx in 0..bit_matrix.getWidth() {
664 if bit_matrix.get(gx, 0) {
665 let (rx, ry, rw, rh) =
666 Self::transform_1d_bar(orientation, x, y, (gx * mw) as i32, 0, mw, bh, bw, bh);
667 let px = self.d2pt(rx as f64);
668 let py = self.height_pt - self.d2pt(ry as f64 + rh as f64);
669 let pw = self.d2pt(rw as f64);
670 let ph = self.d2pt(rh as f64);
671 self.emit_nums(&[px, py, pw, ph], "re");
672 }
673 }
674 if reverse_print {
675 self.emit_op("W");
677 self.emit_op("n");
678 self.fill_inverse_backdrop(x as f64, y as f64, full_w as f64, full_h as f64);
679 } else {
680 self.emit_op("f");
681 }
682 self.restore_state();
683
684 if interpretation_line == 'Y' {
686 self.draw_interpretation_line(
687 x,
688 y,
689 full_w,
690 full_h,
691 mw,
692 data,
693 interpretation_line_above,
694 )?;
695 }
696
697 Ok(())
698 }
699
700 #[allow(clippy::too_many_arguments)]
701 fn draw_interpretation_line(
702 &mut self,
703 x: u32,
704 y: u32,
705 full_w: u32,
706 full_h: u32,
707 module_width: u32,
708 data: &str,
709 interpretation_line_above: char,
710 ) -> ZplResult<()> {
711 {
712 let font_char = '0';
713 let (text_h, gap) = crate::engine::font::interpretation_metrics(module_width);
714 let text_y = if interpretation_line_above == 'Y' {
715 y.saturating_sub(text_h + gap)
716 } else {
717 y + full_h + gap
718 };
719
720 let text_width = self.get_text_width(data, font_char, Some(text_h), None);
721 let text_x = if full_w > text_width {
722 x + (full_w - text_width) / 2
723 } else {
724 x
725 };
726
727 self.draw_text(
728 text_x,
729 text_y,
730 font_char,
731 Some(text_h),
732 None,
733 'N',
734 data,
735 false,
736 None,
737 )?;
738 }
739
740 Ok(())
741 }
742
743 #[allow(clippy::too_many_arguments)]
747 fn fill_matrix_cells(
748 &mut self,
749 x: u32,
750 y: u32,
751 orientation: char,
752 cell_w: u32,
753 cell_h: u32,
754 bit_matrix: &BitMatrix,
755 reverse_print: bool,
756 ) {
757 let bw = bit_matrix.getWidth();
758 let bh = bit_matrix.getHeight();
759 let full_w = bw * cell_w;
760 let full_h = bh * cell_h;
761
762 self.save_state();
763 if !reverse_print {
764 self.set_fill_color(0.0, 0.0, 0.0);
765 }
766
767 for gy in 0..bh {
768 for gx in 0..bw {
769 if bit_matrix.get(gx, gy) {
770 let (rx, ry, rw, rh) = Self::transform_2d_cell(
771 orientation,
772 x,
773 y,
774 (gx * cell_w) as i32,
775 (gy * cell_h) as i32,
776 cell_w,
777 cell_h,
778 full_w,
779 full_h,
780 );
781 let px = self.d2pt(rx as f64);
782 let py = self.height_pt - self.d2pt(ry as f64 + rh as f64);
783 let pw = self.d2pt(rw as f64);
784 let ph = self.d2pt(rh as f64);
785 self.emit_nums(&[px, py, pw, ph], "re");
786 }
787 }
788 }
789 if reverse_print {
790 self.emit_op("W");
791 self.emit_op("n");
792 let (fw, fh) = match orientation {
793 'R' | 'B' => (full_h, full_w),
794 _ => (full_w, full_h),
795 };
796 self.fill_inverse_backdrop(x as f64, y as f64, fw as f64, fh as f64);
797 } else {
798 self.emit_op("f");
799 }
800 self.restore_state();
801 }
802}
803
804impl ZplForgeBackend for PdfNativeBackend {
807 fn setup_page(&mut self, width: f64, height: f64, resolution: f32) {
808 let dpi = if resolution == 0.0 { 203.2 } else { resolution };
809 self.width_dots = width;
810 self.height_dots = height;
811 self.resolution = dpi;
812 self.scale = 72.0 / dpi as f64;
813 self.width_pt = width * self.scale;
814 self.height_pt = height * self.scale;
815 }
816
817 fn setup_font_manager(&mut self, font_manager: &FontManager) {
818 self.font_manager = Some(Arc::new(font_manager.clone()));
819 }
820
821 fn new_page(&mut self) -> ZplResult<()> {
822 self.finished_pages.push(std::mem::take(&mut self.content));
823 self.backdrop_rects.clear();
824 Ok(())
825 }
826
827 fn draw_text(
830 &mut self,
831 x: u32,
832 y: u32,
833 font: char,
834 height: Option<u32>,
835 width: Option<u32>,
836 orientation: char,
837 text: &str,
838 reverse_print: bool,
839 color: Option<String>,
840 ) -> ZplResult<()> {
841 if text.is_empty() {
842 return Ok(());
843 }
844
845 let layout = {
846 let fm = self
847 .font_manager
848 .as_ref()
849 .ok_or_else(|| ZplError::FontError("Font manager not initialized".into()))?;
850 fm.text_layout(font, height, width)
851 .ok_or_else(|| ZplError::FontError(format!("Font not found: {}", font)))?
852 .1
853 };
854
855 self.used_fonts.insert(font);
856
857 let em_x_pt = self.d2pt(layout.em_x as f64);
860 let em_y_pt = self.d2pt(layout.em_y as f64);
861 let baseline_dots = layout.baseline as f64;
863 let h_dots = layout.cell_h as f64;
864 let x = x as f64;
865 let y = y as f64;
866
867 let tw_dots = if reverse_print || orientation == 'I' || orientation == 'B' {
869 self.get_text_width(text, font, height, width) as f64
870 } else {
871 0.0
872 };
873
874 let tm = match orientation {
877 'R' => [
878 0.0,
879 -em_x_pt,
880 em_y_pt,
881 0.0,
882 self.x_pt(x + h_dots - baseline_dots),
883 self.height_pt - y * self.scale,
884 ],
885 'I' => [
886 -em_x_pt,
887 0.0,
888 0.0,
889 -em_y_pt,
890 self.x_pt(x + tw_dots),
891 self.height_pt - (y + h_dots - baseline_dots) * self.scale,
892 ],
893 'B' => [
894 0.0,
895 em_x_pt,
896 -em_y_pt,
897 0.0,
898 self.x_pt(x + baseline_dots),
899 self.height_pt - (y + tw_dots) * self.scale,
900 ],
901 _ => [
902 em_x_pt,
903 0.0,
904 0.0,
905 em_y_pt,
906 self.x_pt(x),
907 self.height_pt - (y + baseline_dots) * self.scale,
908 ],
909 };
910
911 self.save_state();
912 if !reverse_print {
913 let (r, g, b) = Self::parse_hex_color_f64(&color);
914 self.set_fill_color(r, g, b);
915 }
916
917 self.emit_op("BT");
918 if reverse_print {
919 self.emit_nums(&[7.0], "Tr");
921 }
922 self.emit_nums(&tm, "Tm");
923 let font_resource_name = format!("F_{}", font);
924 self.emit_name_op(&format!("{} 1", font_resource_name), "Tf");
925 self.emit_tj(text);
926 self.emit_op("ET");
927
928 if reverse_print {
929 let (bw_dots, bh_dots) = match orientation {
930 'R' | 'B' => (h_dots, tw_dots),
931 _ => (tw_dots, h_dots),
932 };
933 self.fill_inverse_backdrop(x, y, bw_dots, bh_dots);
934 }
935 self.restore_state();
936
937 Ok(())
938 }
939
940 fn draw_graphic_box(
943 &mut self,
944 x: u32,
945 y: u32,
946 width: u32,
947 height: u32,
948 thickness: u32,
949 color: char,
950 custom_color: Option<String>,
951 rounding: u32,
952 reverse_print: bool,
953 ) -> ZplResult<()> {
954 let w = max(width, 1) as f64;
955 let h = max(height, 1) as f64;
956 let t = thickness as f64;
957 let r_dots = rounding as f64 * 8.0;
958
959 let (draw_color, clear_color) = Self::resolve_colors(color, &custom_color);
960
961 let bx = self.x_pt(x as f64);
962 let by = self.y_pt_bottom(y as f64, h);
963 let bw = self.d2pt(w);
964 let bh = self.d2pt(h);
965 let br = self.d2pt(r_dots);
966
967 let has_inner = t * 2.0 < w && t * 2.0 < h;
968
969 if reverse_print {
970 self.save_state();
973 self.push_rounded_rect_path(bx, by, bw, bh, br);
974 if has_inner {
975 let tp = self.d2pt(t);
976 let inner_r = self.d2pt((r_dots - t).max(0.0));
977 self.push_rounded_rect_path(
978 bx + tp,
979 by + tp,
980 bw - tp * 2.0,
981 bh - tp * 2.0,
982 inner_r,
983 );
984 self.emit_op("W*");
985 } else {
986 self.emit_op("W");
987 }
988 self.emit_op("n");
989 self.fill_inverse_backdrop(x as f64, y as f64, w, h);
990 self.restore_state();
991 } else {
992 self.save_state();
993 let (r, g, b) = draw_color;
994 self.set_fill_color(r, g, b);
995 self.push_rounded_rect_path(bx, by, bw, bh, br);
996 self.emit_op("f");
997 self.track_backdrop_rect(x as f64, y as f64, w, h, draw_color);
998
999 if has_inner {
1000 let (cr, cg, cb) = clear_color;
1001 self.set_fill_color(cr, cg, cb);
1002 let tp = self.d2pt(t);
1003 let inner_r = self.d2pt((r_dots - t).max(0.0));
1004 self.push_rounded_rect_path(
1005 bx + tp,
1006 by + tp,
1007 bw - tp * 2.0,
1008 bh - tp * 2.0,
1009 inner_r,
1010 );
1011 self.emit_op("f");
1012 self.track_backdrop_rect(
1013 x as f64 + t,
1014 y as f64 + t,
1015 w - t * 2.0,
1016 h - t * 2.0,
1017 clear_color,
1018 );
1019 }
1020 self.restore_state();
1021 }
1022
1023 Ok(())
1024 }
1025
1026 fn draw_graphic_circle(
1029 &mut self,
1030 x: u32,
1031 y: u32,
1032 radius: u32,
1033 thickness: u32,
1034 _color: char,
1035 custom_color: Option<String>,
1036 reverse_print: bool,
1037 ) -> ZplResult<()> {
1038 let (draw_color, _) = Self::resolve_colors('B', &custom_color);
1039
1040 let r_pt = self.d2pt(radius as f64);
1041 let cx_pt = self.x_pt(x as f64) + r_pt;
1043 let cy_pt = self.height_pt - (y as f64 + radius as f64) * self.scale;
1044
1045 if reverse_print {
1046 self.save_state();
1047 self.push_ellipse_path(cx_pt, cy_pt, r_pt, r_pt);
1048 if radius > thickness {
1049 let inner_r = self.d2pt((radius - thickness) as f64);
1050 self.push_ellipse_path(cx_pt, cy_pt, inner_r, inner_r);
1051 self.emit_op("W*");
1052 } else {
1053 self.emit_op("W");
1054 }
1055 self.emit_op("n");
1056 self.fill_inverse_backdrop(
1057 x as f64,
1058 y as f64,
1059 radius as f64 * 2.0,
1060 radius as f64 * 2.0,
1061 );
1062 self.restore_state();
1063 } else {
1064 self.save_state();
1065 let (r, g, b) = draw_color;
1066 self.set_fill_color(r, g, b);
1067 self.push_ellipse_path(cx_pt, cy_pt, r_pt, r_pt);
1068 self.emit_op("f");
1069
1070 if radius > thickness {
1071 self.set_fill_color(1.0, 1.0, 1.0);
1072 let inner_r = self.d2pt((radius - thickness) as f64);
1073 self.push_ellipse_path(cx_pt, cy_pt, inner_r, inner_r);
1074 self.emit_op("f");
1075 }
1076 self.restore_state();
1077 }
1078
1079 Ok(())
1080 }
1081
1082 fn draw_graphic_ellipse(
1085 &mut self,
1086 x: u32,
1087 y: u32,
1088 width: u32,
1089 height: u32,
1090 thickness: u32,
1091 _color: char,
1092 custom_color: Option<String>,
1093 reverse_print: bool,
1094 ) -> ZplResult<()> {
1095 let (draw_color, _) = Self::resolve_colors('B', &custom_color);
1096
1097 let rx_pt = self.d2pt(width as f64 / 2.0);
1098 let ry_pt = self.d2pt(height as f64 / 2.0);
1099 let cx_pt = self.x_pt(x as f64) + rx_pt;
1100 let cy_pt = self.height_pt - (y as f64 + height as f64 / 2.0) * self.scale;
1101
1102 let t = thickness as f64;
1103
1104 if reverse_print {
1105 self.save_state();
1106 self.push_ellipse_path(cx_pt, cy_pt, rx_pt, ry_pt);
1107 if (width as f64 / 2.0) > t && (height as f64 / 2.0) > t {
1108 let irx = self.d2pt(width as f64 / 2.0 - t);
1109 let iry = self.d2pt(height as f64 / 2.0 - t);
1110 self.push_ellipse_path(cx_pt, cy_pt, irx, iry);
1111 self.emit_op("W*");
1112 } else {
1113 self.emit_op("W");
1114 }
1115 self.emit_op("n");
1116 self.fill_inverse_backdrop(x as f64, y as f64, width as f64, height as f64);
1117 self.restore_state();
1118 } else {
1119 self.save_state();
1120 let (r, g, b) = draw_color;
1121 self.set_fill_color(r, g, b);
1122 self.push_ellipse_path(cx_pt, cy_pt, rx_pt, ry_pt);
1123 self.emit_op("f");
1124
1125 if (width as f64 / 2.0) > t && (height as f64 / 2.0) > t {
1126 self.set_fill_color(1.0, 1.0, 1.0);
1127 let irx = self.d2pt(width as f64 / 2.0 - t);
1128 let iry = self.d2pt(height as f64 / 2.0 - t);
1129 self.push_ellipse_path(cx_pt, cy_pt, irx, iry);
1130 self.emit_op("f");
1131 }
1132 self.restore_state();
1133 }
1134
1135 Ok(())
1136 }
1137
1138 fn draw_graphic_field(
1141 &mut self,
1142 x: u32,
1143 y: u32,
1144 width: u32,
1145 height: u32,
1146 data: &[u8],
1147 reverse_print: bool,
1148 ) -> ZplResult<()> {
1149 if width == 0 || height == 0 {
1150 return Ok(());
1151 }
1152
1153 let row_bytes = width.div_ceil(8) as usize;
1157 let total_bytes = row_bytes * height as usize;
1158 let mut bits = data.to_vec();
1159 bits.resize(total_bytes, 0x00);
1160
1161 self.embed_mask_image(x as f64, y as f64, width, height, bits, reverse_print);
1162 Ok(())
1163 }
1164
1165 fn draw_graphic_image_custom(
1168 &mut self,
1169 x: u32,
1170 y: u32,
1171 width: u32,
1172 height: u32,
1173 data: &str,
1174 ) -> ZplResult<()> {
1175 let image_data = general_purpose::STANDARD
1176 .decode(data.trim())
1177 .map_err(|e| ZplError::ImageError(format!("Failed to decode base64: {}", e)))?;
1178
1179 let img = image::load_from_memory(&image_data)
1180 .map_err(|e| ZplError::ImageError(format!("Failed to load image: {}", e)))?
1181 .to_rgb8();
1182
1183 let (orig_w, orig_h) = img.dimensions();
1184 let (target_w, target_h) = match (width, height) {
1185 (0, 0) => (orig_w, orig_h),
1186 (w, 0) => {
1187 let h = (orig_h as f32 * (w as f32 / orig_w as f32)).round() as u32;
1188 (w, h)
1189 }
1190 (0, h) => {
1191 let w = (orig_w as f32 * (h as f32 / orig_h as f32)).round() as u32;
1192 (w, h)
1193 }
1194 (w, h) => (w, h),
1195 };
1196
1197 let final_img = if target_w != orig_w || target_h != orig_h {
1198 image::imageops::resize(
1199 &img,
1200 target_w,
1201 target_h,
1202 image::imageops::FilterType::Lanczos3,
1203 )
1204 } else {
1205 img
1206 };
1207
1208 let rgb_data = final_img.into_raw();
1209 self.embed_rgb_image(x as f64, y as f64, target_w, target_h, rgb_data);
1210 Ok(())
1211 }
1212
1213 fn draw_code128(
1216 &mut self,
1217 x: u32,
1218 y: u32,
1219 orientation: char,
1220 height: u32,
1221 module_width: u32,
1222 interpretation_line: char,
1223 interpretation_line_above: char,
1224 _check_digit: char,
1225 _mode: char,
1226 data: &str,
1227 reverse_print: bool,
1228 ) -> ZplResult<()> {
1229 let (clean_data, hint_val) = if let Some(stripped) = data.strip_prefix(">:") {
1230 (stripped, Some("B"))
1231 } else if let Some(stripped) = data.strip_prefix(">;") {
1232 (stripped, Some("C"))
1233 } else if let Some(stripped) = data.strip_prefix(">9") {
1234 (stripped, Some("A"))
1235 } else {
1236 (data, Some("B")) };
1238
1239 let hints = hint_val.map(|v| {
1240 let mut h = HashMap::new();
1241 h.insert(
1242 EncodeHintType::FORCE_CODE_SET,
1243 EncodeHintValue::ForceCodeSet(v.to_string()),
1244 );
1245 EncodeHints::from(h)
1246 });
1247
1248 self.draw_1d_barcode(
1249 x,
1250 y,
1251 orientation,
1252 height,
1253 module_width,
1254 clean_data,
1255 BarcodeFormat::CODE_128,
1256 reverse_print,
1257 interpretation_line,
1258 interpretation_line_above,
1259 hints,
1260 hint_val.unwrap_or(""),
1261 )
1262 }
1263
1264 fn draw_qr_code(
1267 &mut self,
1268 x: u32,
1269 y: u32,
1270 orientation: char,
1271 _model: u32,
1272 magnification: u32,
1273 error_correction: char,
1274 _mask: u32,
1275 data: &str,
1276 reverse_print: bool,
1277 ) -> ZplResult<()> {
1278 let level = match error_correction {
1279 'L' => "L",
1280 'M' => "M",
1281 'Q' => "Q",
1282 'H' => "H",
1283 _ => "M",
1284 };
1285
1286 let mut hints = HashMap::new();
1287 hints.insert(
1288 EncodeHintType::ERROR_CORRECTION,
1289 EncodeHintValue::ErrorCorrection(level.to_string()),
1290 );
1291 hints.insert(
1292 EncodeHintType::MARGIN,
1293 EncodeHintValue::Margin("0".to_owned()),
1294 );
1295 let hints: EncodeHints = hints.into();
1296
1297 let bit_matrix = barcode_cache::encode_cached(
1298 BarcodeFormat::QR_CODE,
1299 data,
1300 &format!("ec:{}", level),
1301 Some(&hints),
1302 )?;
1303
1304 let mag = max(magnification, 1);
1305 self.fill_matrix_cells(x, y, orientation, mag, mag, &bit_matrix, reverse_print);
1306 Ok(())
1307 }
1308
1309 fn draw_datamatrix(
1312 &mut self,
1313 x: u32,
1314 y: u32,
1315 orientation: char,
1316 module_size: u32,
1317 data: &str,
1318 reverse_print: bool,
1319 ) -> ZplResult<()> {
1320 let bit_matrix = barcode_cache::encode_cached(BarcodeFormat::DATA_MATRIX, data, "", None)?;
1321
1322 let m = max(module_size, 1);
1323 self.fill_matrix_cells(x, y, orientation, m, m, &bit_matrix, reverse_print);
1324 Ok(())
1325 }
1326
1327 fn draw_pdf417(
1330 &mut self,
1331 x: u32,
1332 y: u32,
1333 orientation: char,
1334 row_height: u32,
1335 module_width: u32,
1336 security_level: u32,
1337 data: &str,
1338 reverse_print: bool,
1339 ) -> ZplResult<()> {
1340 let mut hints = HashMap::new();
1341 hints.insert(
1342 EncodeHintType::ERROR_CORRECTION,
1343 EncodeHintValue::ErrorCorrection(security_level.min(8).to_string()),
1344 );
1345 hints.insert(
1346 EncodeHintType::MARGIN,
1347 EncodeHintValue::Margin("0".to_owned()),
1348 );
1349 let hints: EncodeHints = hints.into();
1350
1351 let bit_matrix = barcode_cache::encode_cached(
1352 BarcodeFormat::PDF_417,
1353 data,
1354 &format!("ec:{}", security_level.min(8)),
1355 Some(&hints),
1356 )?;
1357
1358 let cw = max(module_width, 1);
1359 let ch = max(row_height, 1);
1360 self.fill_matrix_cells(x, y, orientation, cw, ch, &bit_matrix, reverse_print);
1361 Ok(())
1362 }
1363
1364 fn draw_code39(
1367 &mut self,
1368 x: u32,
1369 y: u32,
1370 orientation: char,
1371 _check_digit: char,
1372 height: u32,
1373 module_width: u32,
1374 interpretation_line: char,
1375 interpretation_line_above: char,
1376 data: &str,
1377 reverse_print: bool,
1378 ) -> ZplResult<()> {
1379 self.draw_1d_barcode(
1380 x,
1381 y,
1382 orientation,
1383 height,
1384 module_width,
1385 data,
1386 BarcodeFormat::CODE_39,
1387 reverse_print,
1388 interpretation_line,
1389 interpretation_line_above,
1390 None,
1391 "",
1392 )
1393 }
1394
1395 fn draw_barcode_1d(
1398 &mut self,
1399 kind: Barcode1DKind,
1400 x: u32,
1401 y: u32,
1402 orientation: char,
1403 height: u32,
1404 module_width: u32,
1405 interpretation_line: char,
1406 interpretation_line_above: char,
1407 data: &str,
1408 reverse_print: bool,
1409 ) -> ZplResult<()> {
1410 self.draw_1d_barcode(
1411 x,
1412 y,
1413 orientation,
1414 height,
1415 module_width,
1416 data,
1417 barcode_1d_format(kind),
1418 reverse_print,
1419 interpretation_line,
1420 interpretation_line_above,
1421 None,
1422 "",
1423 )
1424 }
1425
1426 fn draw_graphic_diagonal(
1429 &mut self,
1430 x: u32,
1431 y: u32,
1432 width: u32,
1433 height: u32,
1434 thickness: u32,
1435 color: char,
1436 custom_color: Option<String>,
1437 diagonal_orientation: char,
1438 reverse_print: bool,
1439 ) -> ZplResult<()> {
1440 let (draw_color, _) = Self::resolve_colors(color, &custom_color);
1441
1442 let w = max(width, 1) as f64;
1443 let h = max(height, 1) as f64;
1444 let t = (max(thickness, 1) as f64).min(w);
1445 let x = x as f64;
1446 let y = y as f64;
1447
1448 let pts: [(f64, f64); 4] = if diagonal_orientation == 'L' {
1450 [(x, y), (x + t, y), (x + w, y + h), (x + w - t, y + h)]
1452 } else {
1453 [(x, y + h), (x + t, y + h), (x + w, y), (x + w - t, y)]
1455 };
1456
1457 self.save_state();
1458 if !reverse_print {
1459 let (r, g, b) = draw_color;
1460 self.set_fill_color(r, g, b);
1461 }
1462
1463 for (i, (dx, dy)) in pts.iter().enumerate() {
1464 let px = self.x_pt(*dx);
1465 let py = self.height_pt - dy * self.scale;
1466 self.emit_nums(&[px, py], if i == 0 { "m" } else { "l" });
1467 }
1468 self.emit_op("h");
1469 if reverse_print {
1470 self.emit_op("W");
1471 self.emit_op("n");
1472 self.fill_inverse_backdrop(x, y, w, h);
1473 } else {
1474 self.emit_op("f");
1475 }
1476 self.restore_state();
1477
1478 Ok(())
1479 }
1480
1481 fn finalize(&mut self) -> ZplResult<Vec<u8>> {
1484 let mut doc = Document::with_version("1.5");
1485 let pages_id = doc.new_object_id();
1486
1487 let default_font_bytes: &[u8] = include_bytes!("../assets/IosevkaTermSlab-Regular.ttf");
1495 let mut font_dict = lopdf::Dictionary::new();
1496 let mut embedded_fonts: HashMap<String, lopdf::ObjectId> = HashMap::new();
1498 let tounicode_id = doc.add_object(Stream::new(dictionary! {}, build_tounicode_cmap()));
1499
1500 for font_char in &self.used_fonts {
1501 let font_key = font_char.to_string();
1502 let resource_name = format!("F_{}", font_char);
1503
1504 let actual_name = self
1505 .font_manager
1506 .as_ref()
1507 .and_then(|fm| fm.get_font_name(&font_key).map(|s| s.to_string()))
1508 .unwrap_or_else(|| "Iosevka Term Slab".to_string());
1509
1510 if let Some(font_id) = embedded_fonts.get(&actual_name) {
1511 font_dict.set(resource_name.as_str(), *font_id);
1512 continue;
1513 }
1514
1515 let raw_bytes = self
1516 .font_manager
1517 .as_ref()
1518 .and_then(|fm| fm.get_font_bytes(&font_key))
1519 .unwrap_or(default_font_bytes);
1520
1521 let face = FontArc::try_from_vec(raw_bytes.to_vec())
1522 .map_err(|e| ZplError::FontError(format!("Invalid font data: {}", e)))?;
1523 let upem = face.units_per_em().unwrap_or(1000.0) as f64;
1524 let to_glyph_space = |v: f64| (v * 1000.0 / upem).round() as i64;
1525
1526 let widths: Vec<Object> = (0x20..=0xFFu32)
1528 .map(|code| {
1529 let w = winansi_to_char(code as u8)
1530 .map(|ch| to_glyph_space(face.h_advance_unscaled(face.glyph_id(ch)) as f64))
1531 .unwrap_or(0);
1532 w.into()
1533 })
1534 .collect();
1535
1536 let fd = FontData::new(raw_bytes, actual_name.clone());
1538
1539 let font_stream = Stream::new(
1540 dictionary! { "Length1" => raw_bytes.len() as i64 },
1541 raw_bytes.to_vec(),
1542 );
1543 let font_file_id = doc.add_object(font_stream);
1544
1545 let descriptor_id = doc.add_object(dictionary! {
1546 "Type" => "FontDescriptor",
1547 "FontName" => Object::Name(actual_name.clone().into_bytes()),
1548 "Flags" => 32_i64,
1549 "FontBBox" => vec![
1550 to_glyph_space(fd.font_bbox.0 as f64).into(),
1551 to_glyph_space(fd.font_bbox.1 as f64).into(),
1552 to_glyph_space(fd.font_bbox.2 as f64).into(),
1553 to_glyph_space(fd.font_bbox.3 as f64).into(),
1554 ],
1555 "ItalicAngle" => fd.italic_angle,
1556 "Ascent" => to_glyph_space(fd.ascent as f64),
1557 "Descent" => to_glyph_space(fd.descent as f64),
1558 "CapHeight" => to_glyph_space(fd.cap_height as f64),
1559 "StemV" => 80_i64,
1560 "FontFile2" => font_file_id,
1561 });
1562
1563 let font_id = doc.add_object(dictionary! {
1564 "Type" => "Font",
1565 "Subtype" => "TrueType",
1566 "BaseFont" => Object::Name(actual_name.clone().into_bytes()),
1567 "FirstChar" => 32_i64,
1568 "LastChar" => 255_i64,
1569 "Widths" => widths,
1570 "FontDescriptor" => descriptor_id,
1571 "Encoding" => "WinAnsiEncoding",
1572 "ToUnicode" => tounicode_id,
1573 });
1574
1575 font_dict.set(resource_name.as_str(), font_id);
1576 embedded_fonts.insert(actual_name, font_id);
1577 }
1578
1579 let mut xobject_dict = lopdf::Dictionary::new();
1581 for img in &self.images {
1582 let mut encoder = ZlibEncoder::new(Vec::new(), self.compression);
1583 encoder
1584 .write_all(&img.data)
1585 .map_err(|e| ZplError::BackendError(e.to_string()))?;
1586 let compressed = encoder
1587 .finish()
1588 .map_err(|e| ZplError::BackendError(e.to_string()))?;
1589
1590 let dict = if img.is_mask {
1591 dictionary! {
1594 "Type" => "XObject",
1595 "Subtype" => "Image",
1596 "Width" => img.width as i64,
1597 "Height" => img.height as i64,
1598 "ImageMask" => true,
1599 "BitsPerComponent" => 1,
1600 "Decode" => vec![0.into(), 1.into()],
1601 "Filter" => "FlateDecode",
1602 }
1603 } else {
1604 dictionary! {
1605 "Type" => "XObject",
1606 "Subtype" => "Image",
1607 "Width" => img.width as i64,
1608 "Height" => img.height as i64,
1609 "ColorSpace" => "DeviceRGB",
1610 "BitsPerComponent" => 8,
1611 "Filter" => "FlateDecode",
1612 }
1613 };
1614 let img_stream = Stream::new(dict, compressed);
1615 let img_id = doc.add_object(img_stream);
1616 xobject_dict.set(img.name.as_str(), img_id);
1617 }
1618
1619 let resources_id = doc.add_object(dictionary! {
1621 "Font" => lopdf::Object::Dictionary(font_dict),
1622 "XObject" => lopdf::Object::Dictionary(xobject_dict),
1623 });
1624
1625 let mut page_contents = std::mem::take(&mut self.finished_pages);
1627 page_contents.push(std::mem::take(&mut self.content));
1628
1629 let mut kids: Vec<Object> = Vec::with_capacity(page_contents.len());
1630 for content_bytes in page_contents {
1631 let content_id = doc.add_object(Stream::new(dictionary! {}, content_bytes));
1632 let page_id = doc.add_object(dictionary! {
1633 "Type" => "Page",
1634 "Parent" => pages_id,
1635 "MediaBox" => vec![
1636 0.into(),
1637 0.into(),
1638 Object::Real(self.width_pt as f32),
1639 Object::Real(self.height_pt as f32),
1640 ],
1641 "Contents" => content_id,
1642 "Resources" => resources_id,
1643 });
1644 kids.push(page_id.into());
1645 }
1646
1647 let pages_dict = dictionary! {
1649 "Type" => "Pages",
1650 "Count" => kids.len() as i64,
1651 "Kids" => kids,
1652 };
1653 doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
1654
1655 let catalog_id = doc.add_object(dictionary! {
1657 "Type" => "Catalog",
1658 "Pages" => pages_id,
1659 });
1660 doc.trailer.set("Root", catalog_id);
1661
1662 let mut info = lopdf::Dictionary::new();
1664 info.set(
1665 "Producer",
1666 Object::string_literal(concat!("zpl-forge ", env!("CARGO_PKG_VERSION"))),
1667 );
1668 if let Some(title) = &self.title {
1669 info.set("Title", Object::string_literal(title.as_str()));
1670 }
1671 let info_id = doc.add_object(Object::Dictionary(info));
1672 doc.trailer.set("Info", info_id);
1673
1674 doc.compress();
1675
1676 let mut buf = std::io::BufWriter::new(Vec::new());
1678 doc.save_to(&mut buf)
1679 .map_err(|e| ZplError::BackendError(format!("Failed to save PDF: {}", e)))?;
1680 buf.into_inner()
1681 .map_err(|e| ZplError::BackendError(format!("Failed to flush: {}", e)))
1682 }
1683}