1#[cfg(not(feature = "std"))]
23use alloc::{format, string::String, sync::Arc, vec, vec::Vec};
24#[cfg(feature = "std")]
25use std::sync::Arc;
26
27use crate::{
28 annotation::Shape,
29 djvu_document::{DjVuBookmark, DjVuDocument, DjVuPage, DocError},
30 djvu_render::{self, RenderOptions},
31 export_control::{ExportObserver, NoOpObserver},
32 text::Rect,
33};
34
35#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum PdfError {
41 #[error("document error: {0}")]
43 Doc(#[from] DocError),
44 #[error("render error: {0}")]
46 Render(#[from] djvu_render::RenderError),
47 #[error("io error: {0}")]
49 Io(#[from] std::io::Error),
50 #[error("export cancelled")]
52 Cancelled,
53}
54
55struct PdfWriter<W: std::io::Write> {
63 sink: W,
64 written: usize,
66 offsets: Vec<(usize, usize)>,
68 next_id: usize,
69}
70
71impl<W: std::io::Write> PdfWriter<W> {
72 fn new(sink: W) -> Result<Self, PdfError> {
74 let mut w = PdfWriter {
75 sink,
76 written: 0,
77 offsets: Vec::new(),
78 next_id: 1,
79 };
80 w.write_all(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?;
81 Ok(w)
82 }
83
84 fn write_all(&mut self, bytes: &[u8]) -> Result<(), PdfError> {
85 self.sink.write_all(bytes)?;
86 self.written += bytes.len();
87 Ok(())
88 }
89
90 fn alloc_id(&mut self) -> usize {
92 let id = self.next_id;
93 self.next_id += 1;
94 id
95 }
96
97 fn add_obj(&mut self, id: usize, body: Vec<u8>) -> Result<(), PdfError> {
99 self.offsets.push((id, self.written));
100 self.write_all(format!("{id} 0 obj\n").as_bytes())?;
101 self.write_all(&body)?;
102 self.write_all(b"\nendobj\n")
103 }
104
105 fn add(&mut self, body: Vec<u8>) -> Result<usize, PdfError> {
107 let id = self.alloc_id();
108 self.add_obj(id, body)?;
109 Ok(id)
110 }
111
112 fn finish(mut self) -> Result<(), PdfError> {
114 let xref_offset = self.written;
115 let max_id = self.offsets.iter().map(|(id, _)| *id).max().unwrap_or(0);
116 let mut tail = format!("xref\n0 {}\n", max_id + 1).into_bytes();
117 tail.extend_from_slice(b"0000000000 65535 f \n");
118
119 let mut offset_map = vec![None; max_id + 1];
120 for (obj_id, off) in &self.offsets {
121 if *obj_id <= max_id {
122 offset_map[*obj_id] = Some(*off);
123 }
124 }
125 for entry in offset_map.iter().skip(1) {
126 match entry {
127 Some(off) => {
128 tail.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
129 }
130 None => tail.extend_from_slice(b"0000000000 65535 f \n"),
131 }
132 }
133
134 tail.extend_from_slice(
135 format!(
136 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
137 max_id + 1,
138 xref_offset
139 )
140 .as_bytes(),
141 );
142 self.write_all(&tail)?;
143 self.sink.flush()?;
144 Ok(())
145 }
146}
147
148fn make_stream(dict_extra: &str, data: &[u8]) -> Vec<u8> {
150 let len = data.len();
151 let mut body = format!("<< /Length {len}{dict_extra} >>\nstream\n").into_bytes();
152 body.extend_from_slice(data);
153 body.extend_from_slice(b"\nendstream");
154 body
155}
156
157fn deflate(data: &[u8]) -> Vec<u8> {
159 miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
160}
161
162fn make_deflate_stream(dict_extra: &str, data: &[u8]) -> Vec<u8> {
164 let compressed = deflate(data);
165 let extra = format!(" /Filter /FlateDecode{dict_extra}");
166 make_stream(&extra, &compressed)
167}
168
169fn encode_rgb_to_jpeg(rgb: &[u8], width: u32, height: u32, quality: u8) -> Vec<u8> {
175 use jpeg_encoder::{ColorType, Encoder};
176 let mut out = Vec::new();
177 let enc = Encoder::new(&mut out, quality);
178 let _ = enc.encode(rgb, width as u16, height as u16, ColorType::Rgb);
181 out
182}
183
184fn make_dct_stream(dict_extra: &str, jpeg_bytes: &[u8]) -> Vec<u8> {
186 let extra = format!(" /Filter /DCTDecode{dict_extra}");
187 make_stream(&extra, jpeg_bytes)
188}
189
190fn make_ccitt_stream(dict_extra: &str, ncols: u32, nrows: u32, bitstream: &[u8]) -> Vec<u8> {
197 let extra = format!(
198 " /Filter /CCITTFaxDecode /DecodeParms\
199 << /K -1 /Columns {ncols} /Rows {nrows} /BlackIs1 true >>{dict_extra}"
200 );
201 make_stream(&extra, bitstream)
202}
203
204fn font_dict() -> Vec<u8> {
209 b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()
210}
211
212fn px_to_pt(px: f32, dpi: f32) -> f32 {
218 px * 72.0 / dpi
219}
220
221fn render_dims(page: &DjVuPage, output_dpi: u32) -> (u32, u32) {
228 let native_dpi = page.dpi().max(1) as f32;
229 if output_dpi == 0 || output_dpi as f32 >= native_dpi {
231 return (page.width() as u32, page.height() as u32);
232 }
233 crate::export_common::size_at_dpi(page, output_dpi as f32)
234}
235
236struct RenderedPage {
245 pt_w: f32,
246 pt_h: f32,
247 is_bilevel_only: bool,
248 img0_body: Option<Vec<u8>>,
253 mask_layers: Vec<MaskLayer>,
258 text_ops: String,
260 link_annot_bodies: Vec<Vec<u8>>,
262}
263
264fn render_page_data(page: &DjVuPage, opts: &PdfOptions) -> Result<RenderedPage, PdfError> {
269 let pw = page.width() as u32;
270 let ph = page.height() as u32;
271 let dpi = page.dpi().max(1) as f32;
272 let pt_w = px_to_pt(pw as f32, dpi);
273 let pt_h = px_to_pt(ph as f32, dpi);
274
275 let is_bilevel_only = page.find_chunk(b"Sjbz").is_some() && page.find_chunk(b"BG44").is_none();
276
277 let (img0_body, mask_layers) = if is_bilevel_only {
278 let mask = collect_mask_stream(page, opts);
280 (mask, Vec::new())
281 } else if opts.mrc
282 && let layers = collect_mask_layers(page, opts)
283 && !layers.is_empty()
284 && let Ok(Some(bg)) = page.extract_background()
285 && bg.width > 0
286 && bg.height > 0
287 {
288 let (rw, rh) = (bg.width, bg.height);
293 let mut rgb = Vec::with_capacity(rw as usize * rh as usize * 3);
294 for px in bg.data.as_chunks::<4>().0 {
295 rgb.extend_from_slice(&px[..3]);
296 }
297 (Some(encode_img0_body(&rgb, rw, rh, opts)), layers)
298 } else {
299 let (rw, rh) = render_dims(page, opts.output_dpi);
300 let render_opts = RenderOptions {
305 width: rw,
306 height: rh,
307 ..RenderOptions::default()
308 };
309 let rgb = render_rgb_for_pdf(page, &render_opts, rw, rh)?;
310
311 (
312 Some(encode_img0_body(&rgb, rw, rh, opts)),
313 collect_mask_layers(page, opts),
314 )
315 };
316
317 let text_ops = build_text_content(page, dpi, pt_h);
318 let link_annot_bodies = collect_link_annot_bodies(page, dpi, pt_h);
319
320 Ok(RenderedPage {
321 pt_w,
322 pt_h,
323 is_bilevel_only,
324 img0_body,
325 mask_layers,
326 text_ops,
327 link_annot_bodies,
328 })
329}
330
331fn encode_img0_body(rgb: &[u8], rw: u32, rh: u32, opts: &PdfOptions) -> Vec<u8> {
335 let img_dict = format!(
336 " /Type /XObject /Subtype /Image /Width {rw} /Height {rh}\
337 /ColorSpace /DeviceRGB /BitsPerComponent 8"
338 );
339 match opts.jpeg_quality {
340 Some(quality) => {
341 let jpeg = encode_rgb_to_jpeg(rgb, rw, rh, quality);
342 if jpeg.is_empty() {
343 make_deflate_stream(&img_dict, rgb)
344 } else if opts.adaptive_raster {
345 let dct_body = make_dct_stream(&img_dict, &jpeg);
346 let deflate_body = make_deflate_stream(&img_dict, rgb);
347 if deflate_body.len() < dct_body.len() {
348 deflate_body
349 } else {
350 dct_body
351 }
352 } else {
353 make_dct_stream(&img_dict, &jpeg)
354 }
355 }
356 None => make_deflate_stream(&img_dict, rgb),
357 }
358}
359
360fn render_rgb_for_pdf(
361 page: &DjVuPage,
362 opts: &RenderOptions,
363 width: u32,
364 height: u32,
365) -> Result<Vec<u8>, PdfError> {
366 let mut rgb = Vec::with_capacity(width as usize * height as usize * 3);
367 crate::export_common::render_rows_or_pixmap(page, opts, |rgba_row| {
368 crate::export_common::rgba_row_to_rgb(&mut rgb, rgba_row);
369 })?;
370 Ok(rgb)
371}
372
373fn collect_mask_stream(page: &DjVuPage, opts: &PdfOptions) -> Option<Vec<u8>> {
386 let bitmap = page.extract_mask().ok()??;
387 Some(mask_body_from_bitmap(&bitmap, opts.ccitt_g4))
388}
389
390fn mask_body_from_bitmap(bitmap: &crate::bitmap::Bitmap, use_g4: bool) -> Vec<u8> {
393 let bw = bitmap.width;
394 let bh = bitmap.height;
395 let dict_extra = format!(
398 " /Type /XObject /Subtype /Image /Width {bw} /Height {bh}\
399 /ImageMask true /BitsPerComponent 1 /Decode [1 0]"
400 );
401 let deflate_body = make_deflate_stream(&dict_extra, &bitmap.data);
402 if !use_g4 {
403 return deflate_body;
404 }
405 let g4_bits = crate::smmr::encode_g4(bitmap);
406 let g4_body = make_ccitt_stream(&dict_extra, bw, bh, &g4_bits);
407 if g4_body.len() < deflate_body.len() {
408 g4_body
409 } else {
410 deflate_body
411 }
412}
413
414struct MaskLayer {
421 rgb: (u8, u8, u8),
422 bbox: (u32, u32, u32, u32),
423 mask_dims: (u32, u32),
424 body: Vec<u8>,
425}
426
427fn collect_mask_layers(page: &DjVuPage, opts: &PdfOptions) -> Vec<MaskLayer> {
435 let palette = page
436 .find_chunk(b"FGbz")
437 .and_then(|d| crate::fgbz::parse_fgbz(d).ok())
438 .filter(|p| !p.colors.is_empty())
439 .filter(|p| p.colors.iter().any(|c| (c.r, c.g, c.b) != (0, 0, 0)));
440
441 let pal = match palette {
442 Some(pal) => pal,
443 None => {
444 if !page.fg44_chunks().is_empty() || page.find_chunk(b"FGjp").is_some() {
453 let Some(mask) = page.decoded_mask() else {
457 return Vec::new();
458 };
459 let fg = match page.decoded_fg44() {
460 Some(fg) => Some(fg),
461 None => page.extract_foreground().ok().flatten().map(Arc::new),
462 };
463 let Some(fg) = fg else {
464 return Vec::new();
465 };
466 return match uniform_fg_color(&fg, &mask) {
467 Some(rgb) => stencil_layer_from_mask(&mask, opts, rgb),
468 None => Vec::new(),
469 };
470 }
471 return black_mask_layer(page, opts);
472 }
473 };
474
475 let Ok(Some((mask, blit_map))) = page.extract_mask_indexed() else {
476 return black_mask_layer(page, opts);
478 };
479
480 let w = mask.width;
485 let h = mask.height;
486 const NO_PIXEL: u16 = u16::MAX;
487 let mut color_of_pixel = vec![NO_PIXEL; w as usize * h as usize];
488 let mut bboxes = vec![(u32::MAX, u32::MAX, 0u32, 0u32); pal.colors.len()];
490 for y in 0..h {
491 for x in 0..w {
492 if !mask.get(x, y) {
493 continue;
494 }
495 let mi = y as usize * w as usize + x as usize;
496 let blit_idx = blit_map.get(mi).copied().unwrap_or(-1);
497 let ci = if blit_idx >= 0 {
498 let raw = if pal.indices.is_empty() {
499 blit_idx as usize
500 } else {
501 pal.indices
502 .get(blit_idx as usize)
503 .map(|&i| i as usize)
504 .unwrap_or(0)
505 };
506 if raw < pal.colors.len() { raw } else { 0 }
507 } else {
508 0
509 };
510 color_of_pixel[mi] = ci as u16;
511 let b = &mut bboxes[ci];
512 b.0 = b.0.min(x);
513 b.1 = b.1.min(y);
514 b.2 = b.2.max(x);
515 b.3 = b.3.max(y);
516 }
517 }
518
519 let mut planes: Vec<Option<crate::bitmap::Bitmap>> = Vec::new();
523 planes.resize_with(pal.colors.len(), || None);
524 for y in 0..h {
525 for x in 0..w {
526 let ci = color_of_pixel[y as usize * w as usize + x as usize];
527 if ci == NO_PIXEL {
528 continue;
529 }
530 let ci = ci as usize;
531 let (x0, y0, x1, y1) = bboxes[ci];
532 planes[ci]
533 .get_or_insert_with(|| crate::bitmap::Bitmap::new(x1 - x0 + 1, y1 - y0 + 1))
534 .set_black(x - x0, y - y0);
535 }
536 }
537
538 planes
539 .into_iter()
540 .enumerate()
541 .filter_map(|(ci, plane)| {
542 let plane = plane?;
543 let c = pal.colors[ci];
544 let (x0, y0, x1, y1) = bboxes[ci];
545 Some(MaskLayer {
546 rgb: (c.r, c.g, c.b),
547 bbox: (x0, y0, x1 - x0 + 1, y1 - y0 + 1),
548 mask_dims: (w, h),
549 body: mask_body_from_bitmap(&plane, true),
553 })
554 })
555 .collect()
556}
557
558fn black_mask_layer(page: &DjVuPage, opts: &PdfOptions) -> Vec<MaskLayer> {
560 if let Some(mask) = page.decoded_mask() {
562 return stencil_layer_from_mask(&mask, opts, (0, 0, 0));
563 }
564 let Ok(Some(bitmap)) = page.extract_mask() else {
565 return Vec::new();
566 };
567 stencil_layer_from_mask(&bitmap, opts, (0, 0, 0))
568}
569
570fn stencil_layer_from_mask(
572 mask: &crate::bitmap::Bitmap,
573 opts: &PdfOptions,
574 rgb: (u8, u8, u8),
575) -> Vec<MaskLayer> {
576 vec![MaskLayer {
577 rgb,
578 bbox: (0, 0, mask.width, mask.height),
579 mask_dims: (mask.width, mask.height),
580 body: mask_body_from_bitmap(mask, opts.ccitt_g4),
581 }]
582}
583
584const FG44_UNIFORM_SPREAD: u8 = 48;
587
588fn uniform_fg_color(fg: &crate::Pixmap, mask: &crate::bitmap::Bitmap) -> Option<(u8, u8, u8)> {
595 if fg.width == 0 || fg.height == 0 || mask.width == 0 || mask.height == 0 {
596 return None;
597 }
598 let mut min = [255u8; 3];
599 let mut max = [0u8; 3];
600 let mut sum = [0u64; 3];
601 let mut n = 0u64;
602 for y in 0..mask.height {
603 let fy = (y as u64 * fg.height as u64 / mask.height as u64).min(fg.height as u64 - 1);
604 for x in 0..mask.width {
605 if !mask.get(x, y) {
606 continue;
607 }
608 let fx = (x as u64 * fg.width as u64 / mask.width as u64).min(fg.width as u64 - 1);
609 let pi = (fy * fg.width as u64 + fx) as usize * 4;
610 let px = &fg.data[pi..pi + 3];
611 for c in 0..3 {
612 min[c] = min[c].min(px[c]);
613 max[c] = max[c].max(px[c]);
614 sum[c] += u64::from(px[c]);
615 }
616 n += 1;
617 }
618 }
619 if n == 0 {
620 return None;
621 }
622 if (0..3).any(|c| max[c] - min[c] > FG44_UNIFORM_SPREAD) {
623 return None;
624 }
625 Some(((sum[0] / n) as u8, (sum[1] / n) as u8, (sum[2] / n) as u8))
626}
627
628fn fmt_rg_component(v: u8) -> String {
633 match v {
634 0 => "0".to_string(),
635 255 => "1".to_string(),
636 _ => format!("{:.4}", f32::from(v) / 255.0),
637 }
638}
639
640fn fmt_pt(v: f32) -> String {
643 if v == 0.0 {
644 "0".to_string()
645 } else {
646 format!("{v:.4}")
647 }
648}
649
650fn collect_link_annot_bodies(page: &DjVuPage, dpi: f32, pt_h: f32) -> Vec<Vec<u8>> {
652 let hyperlinks = match page.hyperlinks() {
653 Ok(links) => links,
654 Err(_) => return Vec::new(),
655 };
656 hyperlinks
657 .iter()
658 .filter_map(|link| {
659 let rect = shape_to_pdf_rect(&link.shape, dpi, pt_h)?;
660 let url_escaped = pdf_escape_string(&link.url);
661 Some(
662 format!(
663 "<< /Type /Annot /Subtype /Link\n\
664 /Rect [{:.4} {:.4} {:.4} {:.4}]\n\
665 /Border [0 0 0]\n\
666 /A << /S /URI /URI ({url_escaped}) >> >>",
667 rect.0, rect.1, rect.2, rect.3
668 )
669 .into_bytes(),
670 )
671 })
672 .collect()
673}
674
675fn emit_page_objects<W: std::io::Write>(
677 w: &mut PdfWriter<W>,
678 data: RenderedPage,
679 pages_id: usize,
680 font_id: usize,
681) -> Result<usize, PdfError> {
682 let pt_w = data.pt_w;
683 let pt_h = data.pt_h;
684
685 let img_id = match data.img0_body {
686 Some(body) => Some(w.add(body)?),
687 None => None,
688 };
689 let mut mask_layers: Vec<(MaskLayer, usize)> = Vec::with_capacity(data.mask_layers.len());
690 for mut layer in data.mask_layers {
691 let body = core::mem::take(&mut layer.body);
692 let id = w.add(body)?;
693 mask_layers.push((layer, id));
694 }
695
696 let mut content = String::new();
697
698 if data.is_bilevel_only {
699 if img_id.is_some() {
701 content.push_str("0 0 0 rg\n");
706 content.push_str(&format!("q {pt_w:.4} 0 0 {pt_h:.4} 0 0 cm /Im0 Do Q\n"));
707 }
708 } else {
709 if img_id.is_some() {
710 content.push_str(&format!("q {pt_w:.4} 0 0 {pt_h:.4} 0 0 cm /Im0 Do Q\n"));
711 }
712 for (i, (layer, _)) in mask_layers.iter().enumerate() {
713 let (r, g, b) = layer.rgb;
714 let (x0, y0, bw, bh) = layer.bbox;
715 let (mw, mh) = layer.mask_dims;
716 let sw = pt_w * bw as f32 / mw as f32;
721 let sh = pt_h * bh as f32 / mh as f32;
722 let tx = pt_w * x0 as f32 / mw as f32;
723 let ty = pt_h * (mh - y0 - bh) as f32 / mh as f32;
724 content.push_str(&format!(
725 "q {} {} {} rg {sw:.4} 0 0 {sh:.4} {} {} cm /Mask{i} Do Q\n",
726 fmt_rg_component(r),
727 fmt_rg_component(g),
728 fmt_rg_component(b),
729 fmt_pt(tx),
730 fmt_pt(ty),
731 ));
732 }
733 }
734
735 if !data.text_ops.is_empty() {
736 content.push_str(&data.text_ops);
737 }
738
739 let content_body = make_deflate_stream("", content.as_bytes());
740 let content_id = w.add(content_body)?;
741
742 let mut resources = String::from("/XObject <<");
743 if let Some(id) = img_id {
744 resources.push_str(&format!(" /Im0 {id} 0 R"));
745 }
746 for (i, (_, mid)) in mask_layers.iter().enumerate() {
747 resources.push_str(&format!(" /Mask{i} {mid} 0 R"));
748 }
749 resources.push_str(" >>");
750 if !data.text_ops.is_empty() {
751 resources.push_str(&format!(" /Font << /F1 {font_id} 0 R >>"));
752 }
753
754 let mut annot_ids: Vec<usize> = Vec::with_capacity(data.link_annot_bodies.len());
755 for body in data.link_annot_bodies {
756 annot_ids.push(w.add(body)?);
757 }
758 let mut annots_str = String::new();
759 if !annot_ids.is_empty() {
760 annots_str.push_str(" /Annots [");
761 for aid in &annot_ids {
762 annots_str.push_str(&format!(" {aid} 0 R"));
763 }
764 annots_str.push_str(" ]");
765 }
766
767 w.add(
768 format!(
769 "<< /Type /Page /Parent {pages_id} 0 R\n\
770 /MediaBox [0 0 {pt_w:.4} {pt_h:.4}]\n\
771 /Contents {content_id} 0 R\n\
772 /Resources << {resources} >>{annots_str} >>"
773 )
774 .into_bytes(),
775 )
776}
777
778fn build_text_content(page: &DjVuPage, dpi: f32, pt_h: f32) -> String {
780 let text_layer = match page.text_layer() {
781 Ok(Some(tl)) => tl,
782 _ => return String::new(),
783 };
784
785 let mut ops = String::new();
786 ops.push_str("BT\n");
788 ops.push_str("3 Tr\n");
790 ops.push_str("/F1 1 Tf\n");
792
793 for span in crate::export_common::word_spans(&text_layer) {
795 emit_word_span(&mut ops, span.rect, span.text, dpi, pt_h);
796 }
797
798 ops.push_str("ET\n");
799
800 if ops == "BT\n3 Tr\n/F1 1 Tf\nET\n" {
801 return String::new();
803 }
804
805 ops
806}
807
808fn emit_word_span(ops: &mut String, rect: &Rect, text: &str, dpi: f32, pt_h: f32) {
815 let x = px_to_pt(rect.x as f32, dpi);
816 let y = pt_h - px_to_pt((rect.y + rect.height) as f32, dpi);
817 let w = px_to_pt(rect.width as f32, dpi);
818 let h = px_to_pt(rect.height as f32, dpi);
819
820 if w <= 0.0 || h <= 0.0 {
821 return;
822 }
823
824 let font_size = h;
826 if font_size < 0.5 {
827 return;
828 }
829
830 let text_escaped = pdf_escape_string(text);
832 let natural_width: f32 = text
834 .chars()
835 .map(|c| helvetica_advance(c) * font_size)
836 .sum::<f32>()
837 .max(0.01);
838 let h_scale = if natural_width > 0.01 {
839 (w / natural_width) * 100.0
840 } else {
841 100.0
842 };
843
844 ops.push_str(&format!(
845 "{font_size:.2} 0 0 {font_size:.2} {x:.4} {y:.4} Tm\n"
846 ));
847 if (h_scale - 100.0).abs() > 1.0 {
848 ops.push_str(&format!("{h_scale:.2} Tz\n"));
849 }
850 ops.push_str(&format!("({text_escaped}) Tj\n"));
851}
852
853fn helvetica_advance(c: char) -> f32 {
860 let cp = c as u32;
861 match c {
862 '\x00'..='\x1f' | '\x7f' => 0.0,
864 ' ' => 0.278,
866 '0'..='9' => 0.556,
868 ',' | '.' | ':' | ';' | '!' | '?' => 0.278,
870 '\'' | '"' => 0.222,
871 '(' | ')' | '[' | ']' | '{' | '}' => 0.333,
872 '-' | '\u{2013}' | '\u{2014}' => 0.333,
873 'A'..='Z' => 0.667,
875 'a'..='z' => 0.556,
877 _ => {
878 if matches!(cp,
880 0x1100..=0x11FF | 0x2E80..=0x2EFF | 0x2F00..=0x2FDF | 0x3000..=0x303F | 0x3040..=0x309F | 0x30A0..=0x30FF | 0x3100..=0x312F | 0x3130..=0x318F | 0x3190..=0x31FF | 0x3200..=0x32FF | 0x3300..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xA000..=0xA48F | 0xA490..=0xA4CF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x1B000..=0x1B0FF | 0x20000..=0x2A6DF | 0x2A700..=0x2CEAF | 0x2CEB0..=0x2EBEF | 0x30000..=0x3134F ) {
906 1.0
907 } else {
908 0.556
910 }
911 }
912 }
913}
914
915fn pdf_escape_string(s: &str) -> String {
917 let mut out = String::with_capacity(s.len());
918 for c in s.chars() {
919 match c {
920 '(' => out.push_str("\\("),
921 ')' => out.push_str("\\)"),
922 '\\' => out.push_str("\\\\"),
923 c if c.is_ascii() => out.push(c),
924 _ => {
926 out.push('?');
929 }
930 }
931 }
932 out
933}
934
935fn shape_to_pdf_rect(shape: &Shape, dpi: f32, _pt_h: f32) -> Option<(f32, f32, f32, f32)> {
945 let r = crate::export_common::shape_bbox(shape)?;
946 let x1 = px_to_pt(r.x as f32, dpi);
947 let y1 = px_to_pt(r.y as f32, dpi);
948 let x2 = px_to_pt((r.x + r.width) as f32, dpi);
949 let y2 = px_to_pt((r.y + r.height) as f32, dpi);
950 Some((x1, y1, x2, y2))
951}
952
953fn build_outline<W: std::io::Write>(
958 w: &mut PdfWriter<W>,
959 bookmarks: &[DjVuBookmark],
960 page_ids: &[usize],
961) -> Result<Option<usize>, PdfError> {
962 if bookmarks.is_empty() {
963 return Ok(None);
964 }
965
966 let outline_id = w.alloc_id();
967
968 let item_ids = build_outline_items(w, bookmarks, outline_id, page_ids)?;
970
971 if item_ids.is_empty() {
972 return Ok(None);
973 }
974
975 let first = item_ids[0];
976 let last = *item_ids.last().unwrap();
977 let count = count_outline_items(bookmarks);
978
979 w.add_obj(
980 outline_id,
981 format!("<< /Type /Outlines /First {first} 0 R /Last {last} 0 R /Count {count} >>")
982 .into_bytes(),
983 )?;
984
985 Ok(Some(outline_id))
986}
987
988fn build_outline_items<W: std::io::Write>(
990 w: &mut PdfWriter<W>,
991 bookmarks: &[DjVuBookmark],
992 parent_id: usize,
993 page_ids: &[usize],
994) -> Result<Vec<usize>, PdfError> {
995 let mut ids = Vec::new();
996
997 for _bm in bookmarks {
998 let item_id = w.alloc_id();
999 ids.push(item_id);
1000 }
1001
1002 for (i, bm) in bookmarks.iter().enumerate() {
1003 let item_id = ids[i];
1004 let prev = if i > 0 {
1005 format!(" /Prev {} 0 R", ids[i - 1])
1006 } else {
1007 String::new()
1008 };
1009 let next = if i + 1 < ids.len() {
1010 format!(" /Next {} 0 R", ids[i + 1])
1011 } else {
1012 String::new()
1013 };
1014
1015 let dest = resolve_bookmark_dest(&bm.url, page_ids);
1017
1018 let child_ids = build_outline_items(w, &bm.children, item_id, page_ids)?;
1020 let children_str = if !child_ids.is_empty() {
1021 let first = child_ids[0];
1022 let last = *child_ids.last().unwrap();
1023 let count = count_outline_items(&bm.children);
1024 format!(" /First {first} 0 R /Last {last} 0 R /Count {count}")
1025 } else {
1026 String::new()
1027 };
1028
1029 let title = pdf_escape_string(&bm.title);
1030 w.add_obj(
1031 item_id,
1032 format!(
1033 "<< /Title ({title}) /Parent {parent_id} 0 R{prev}{next}{dest}{children_str} >>"
1034 )
1035 .into_bytes(),
1036 )?;
1037 }
1038
1039 Ok(ids)
1040}
1041
1042fn count_outline_items(bookmarks: &[DjVuBookmark]) -> usize {
1044 let mut n = bookmarks.len();
1045 for bm in bookmarks {
1046 n += count_outline_items(&bm.children);
1047 }
1048 n
1049}
1050
1051fn resolve_bookmark_dest(url: &str, page_ids: &[usize]) -> String {
1054 if let Some(idx) = crate::export_common::bookmark_page_index(url)
1055 && let Some(&pid) = page_ids.get(idx)
1056 {
1057 return format!(" /Dest [{pid} 0 R /Fit]");
1058 }
1059
1060 if !url.is_empty() {
1062 let escaped = pdf_escape_string(url);
1063 return format!(" /A << /S /URI /URI ({escaped}) >>");
1064 }
1065
1066 String::new()
1067}
1068
1069#[derive(Debug, Clone)]
1081pub struct PdfOptions {
1082 pub jpeg_quality: Option<u8>,
1087
1088 pub output_dpi: u32,
1097
1098 pub adaptive_raster: bool,
1114
1115 pub ccitt_g4: bool,
1131
1132 pub mrc: bool,
1145}
1146
1147impl Default for PdfOptions {
1148 fn default() -> Self {
1149 PdfOptions {
1150 jpeg_quality: Some(80),
1151 output_dpi: 150,
1152 adaptive_raster: false,
1153 ccitt_g4: false,
1154 mrc: false,
1155 }
1156 }
1157}
1158
1159impl PdfOptions {
1160 pub fn archival() -> Self {
1162 PdfOptions {
1163 jpeg_quality: Some(90),
1164 output_dpi: 0,
1165 adaptive_raster: false,
1166 ccitt_g4: false,
1167 mrc: false,
1168 }
1169 }
1170}
1171
1172pub fn djvu_to_pdf_with_options(
1176 doc: &DjVuDocument,
1177 opts: &PdfOptions,
1178) -> Result<Vec<u8>, PdfError> {
1179 let mut buf = Vec::new();
1180 djvu_to_pdf_to_writer(doc, opts, &mut buf)?;
1181 Ok(buf)
1182}
1183
1184pub fn djvu_to_pdf_to_writer<W: std::io::Write>(
1199 doc: &DjVuDocument,
1200 opts: &PdfOptions,
1201 sink: W,
1202) -> Result<(), PdfError> {
1203 let mut observer = NoOpObserver;
1204 djvu_to_pdf_to_writer_with_observer(doc, opts, sink, &mut observer)
1205}
1206
1207pub fn djvu_to_pdf_to_writer_with_observer<W: std::io::Write>(
1217 doc: &DjVuDocument,
1218 opts: &PdfOptions,
1219 sink: W,
1220 observer: &mut dyn ExportObserver,
1221) -> Result<(), PdfError> {
1222 djvu_to_pdf_impl(doc, opts, sink, observer)
1223}
1224
1225pub fn djvu_to_pdf(doc: &DjVuDocument) -> Result<Vec<u8>, PdfError> {
1239 djvu_to_pdf_with_options(doc, &PdfOptions::default())
1240}
1241
1242fn djvu_to_pdf_impl<W: std::io::Write>(
1243 doc: &DjVuDocument,
1244 opts: &PdfOptions,
1245 sink: W,
1246 observer: &mut dyn ExportObserver,
1247) -> Result<(), PdfError> {
1248 let mut w = PdfWriter::new(sink)?;
1249
1250 let catalog_id = w.alloc_id(); let pages_id = w.alloc_id(); let font_id = w.alloc_id(); w.add_obj(font_id, font_dict())?;
1257
1258 let page_count = doc.page_count();
1259
1260 let emit_one = |w: &mut PdfWriter<W>,
1263 i: usize,
1264 rendered: Option<RenderedPage>|
1265 -> Result<usize, PdfError> {
1266 Ok(match rendered {
1267 Some(data) => emit_page_objects(w, data, pages_id, font_id)?,
1268 None => {
1269 let page = doc.page(i)?;
1271 let dpi = page.dpi().max(1) as f32;
1272 let pt_w = px_to_pt(page.width() as f32, dpi);
1273 let pt_h = px_to_pt(page.height() as f32, dpi);
1274 w.add(
1275 format!(
1276 "<< /Type /Page /Parent {pages_id} 0 R\n\
1277 /MediaBox [0 0 {pt_w:.4} {pt_h:.4}]\n\
1278 /Resources << >> >>"
1279 )
1280 .into_bytes(),
1281 )?
1282 }
1283 })
1284 };
1285
1286 let mut page_obj_ids = Vec::with_capacity(page_count);
1287
1288 #[cfg(feature = "parallel")]
1294 {
1295 use rayon::prelude::*;
1296 let chunk = rayon::current_num_threads().max(1) * 8;
1301 let mut start = 0;
1302 while start < page_count {
1303 if observer.cancelled() {
1304 return Err(PdfError::Cancelled);
1305 }
1306 let end = (start + chunk).min(page_count);
1307 let rendered_pages: Vec<Option<RenderedPage>> = (start..end)
1308 .into_par_iter()
1309 .map(|i| {
1310 doc.page(i)
1314 .ok()
1315 .and_then(|p| render_page_data(&p.clone(), opts).ok())
1316 })
1317 .collect();
1318 for (off, rendered) in rendered_pages.into_iter().enumerate() {
1319 if observer.cancelled() {
1320 return Err(PdfError::Cancelled);
1321 }
1322 page_obj_ids.push(emit_one(&mut w, start + off, rendered)?);
1323 observer.on_progress(start + off + 1, page_count);
1324 }
1325 start = end;
1326 }
1327 }
1328
1329 #[cfg(not(feature = "parallel"))]
1333 for i in 0..page_count {
1334 if observer.cancelled() {
1335 return Err(PdfError::Cancelled);
1336 }
1337 let rendered = doc
1339 .page(i)
1340 .ok()
1341 .and_then(|p| render_page_data(&p.clone(), opts).ok());
1342 page_obj_ids.push(emit_one(&mut w, i, rendered)?);
1343 observer.on_progress(i + 1, page_count);
1344 }
1345
1346 let outline_id = build_outline(&mut w, doc.bookmarks(), &page_obj_ids)?;
1348
1349 let kids = page_obj_ids
1351 .iter()
1352 .map(|id| format!("{id} 0 R"))
1353 .collect::<Vec<_>>()
1354 .join(" ");
1355 let n = page_obj_ids.len();
1356 w.add_obj(
1357 pages_id,
1358 format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(),
1359 )?;
1360
1361 let outline_ref = match outline_id {
1363 Some(oid) => format!(" /Outlines {oid} 0 R /PageMode /UseOutlines"),
1364 None => String::new(),
1365 };
1366 w.add_obj(
1367 catalog_id,
1368 format!("<< /Type /Catalog /Pages {pages_id} 0 R{outline_ref} >>").into_bytes(),
1369 )?;
1370
1371 w.finish()
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use super::*;
1377
1378 #[test]
1379 fn test_pdf_escape_string() {
1380 assert_eq!(pdf_escape_string("hello"), "hello");
1381 assert_eq!(pdf_escape_string("a(b)c"), "a\\(b\\)c");
1382 assert_eq!(pdf_escape_string("a\\b"), "a\\\\b");
1383 }
1384
1385 #[test]
1386 fn test_px_to_pt() {
1387 assert!((px_to_pt(72.0, 72.0) - 72.0).abs() < 0.01);
1389 assert!((px_to_pt(300.0, 300.0) - 72.0).abs() < 0.01);
1391 }
1392
1393 #[test]
1394 fn test_resolve_bookmark_dest_page_number() {
1395 let page_ids = vec![10, 20, 30];
1396 let dest = resolve_bookmark_dest("#1", &page_ids);
1397 assert!(dest.contains("10 0 R"));
1398 }
1399
1400 #[test]
1401 fn test_pdf_writer_serialize() {
1402 let mut pdf = Vec::new();
1403 let mut w = PdfWriter::new(&mut pdf).unwrap();
1404 let id = w.add(b"<< /Type /Catalog >>".to_vec()).unwrap();
1405 assert_eq!(id, 1);
1406 w.finish().unwrap();
1407 assert!(pdf.starts_with(b"%PDF-1.4"));
1408 assert!(pdf.windows(5).any(|w| w == b"%%EOF"));
1409 }
1410
1411 #[test]
1412 fn test_make_stream() {
1413 let stream = make_stream(" /Filter /FlateDecode", b"hello");
1414 let s = String::from_utf8_lossy(&stream);
1415 assert!(s.contains("/Length 5"));
1416 assert!(s.contains("stream\nhello\nendstream"));
1417 }
1418
1419 #[test]
1420 fn test_deflate_roundtrip() {
1421 let data = b"hello world, this is a test of deflate compression";
1422 let compressed = deflate(data);
1423 assert!(!compressed.is_empty());
1425 let decompressed = miniz_oxide::inflate::decompress_to_vec_zlib(&compressed).unwrap();
1427 assert_eq!(&decompressed, data);
1428 }
1429
1430 #[test]
1431 fn test_make_deflate_stream() {
1432 let body = make_deflate_stream(" /Type /XObject", b"test data");
1433 let s = String::from_utf8_lossy(&body);
1434 assert!(s.contains("/Filter /FlateDecode"));
1435 assert!(s.contains("/Type /XObject"));
1436 assert!(s.contains("stream\n"));
1437 assert!(s.contains("\nendstream"));
1438 }
1439
1440 #[test]
1441 fn test_font_dict() {
1442 let d = font_dict();
1443 let s = String::from_utf8_lossy(&d);
1444 assert!(s.contains("/Type /Font"));
1445 assert!(s.contains("/BaseFont /Helvetica"));
1446 }
1447
1448 #[test]
1449 fn test_pdf_writer_alloc_ids() {
1450 let mut buf = Vec::new();
1451 let mut w = PdfWriter::new(&mut buf).unwrap();
1452 let id1 = w.alloc_id();
1453 let id2 = w.alloc_id();
1454 let id3 = w.alloc_id();
1455 assert_eq!(id1, 1);
1456 assert_eq!(id2, 2);
1457 assert_eq!(id3, 3);
1458 }
1459
1460 #[test]
1461 fn test_pdf_writer_multiple_objects() {
1462 let mut pdf = Vec::new();
1463 let mut w = PdfWriter::new(&mut pdf).unwrap();
1464 w.add(b"<< /Type /Catalog >>".to_vec()).unwrap();
1465 w.add(b"<< /Type /Pages >>".to_vec()).unwrap();
1466 w.finish().unwrap();
1467 let s = String::from_utf8_lossy(&pdf);
1468 assert!(s.contains("1 0 obj"));
1469 assert!(s.contains("2 0 obj"));
1470 assert!(s.contains("/Size 3")); }
1472
1473 #[test]
1474 fn test_resolve_bookmark_dest_page_prefix() {
1475 let page_ids = vec![10, 20, 30];
1476 let dest = resolve_bookmark_dest("#page2", &page_ids);
1477 assert!(dest.contains("20 0 R"));
1478 assert!(dest.contains("/Fit"));
1479 }
1480
1481 #[test]
1482 fn test_resolve_bookmark_dest_page_underscore() {
1483 let page_ids = vec![10, 20, 30];
1484 let dest = resolve_bookmark_dest("#page_3", &page_ids);
1485 assert!(dest.contains("30 0 R"));
1486 }
1487
1488 #[test]
1489 fn test_resolve_bookmark_dest_out_of_range() {
1490 let page_ids = vec![10];
1491 let dest = resolve_bookmark_dest("#page99", &page_ids);
1492 assert!(!dest.contains("10 0 R"));
1494 }
1495
1496 #[test]
1497 fn test_resolve_bookmark_dest_external_url() {
1498 let page_ids = vec![10];
1499 let dest = resolve_bookmark_dest("http://example.com", &page_ids);
1500 assert!(dest.contains("/S /URI"));
1501 assert!(dest.contains("http://example.com"));
1502 }
1503
1504 #[test]
1505 fn test_resolve_bookmark_dest_empty_url() {
1506 let page_ids = vec![10];
1507 let dest = resolve_bookmark_dest("", &page_ids);
1508 assert!(dest.is_empty());
1509 }
1510
1511 #[test]
1512 fn test_pdf_escape_special_chars() {
1513 assert_eq!(pdf_escape_string("a(b)c\\d"), "a\\(b\\)c\\\\d");
1514 }
1515
1516 #[test]
1517 fn test_pdf_escape_non_ascii() {
1518 let result = pdf_escape_string("caf\u{00e9}");
1520 assert_eq!(result, "caf?");
1521 }
1522
1523 #[test]
1524 fn test_shape_to_pdf_rect_rect() {
1525 use crate::annotation;
1526 let shape = annotation::Shape::Rect(annotation::Rect {
1527 x: 0,
1528 y: 0,
1529 width: 300,
1530 height: 300,
1531 });
1532 let rect = shape_to_pdf_rect(&shape, 300.0, 72.0).unwrap();
1533 assert!((rect.0 - 0.0).abs() < 0.01); assert!((rect.2 - 72.0).abs() < 0.01); }
1536
1537 #[test]
1538 fn test_shape_to_pdf_rect_poly() {
1539 use crate::annotation;
1540 let shape = annotation::Shape::Poly(vec![(0, 0), (300, 0), (300, 300), (0, 300)]);
1541 let rect = shape_to_pdf_rect(&shape, 300.0, 72.0).unwrap();
1542 assert!((rect.0 - 0.0).abs() < 0.01);
1543 assert!((rect.2 - 72.0).abs() < 0.01);
1544 }
1545
1546 #[test]
1547 fn test_shape_to_pdf_rect_empty_poly() {
1548 use crate::annotation;
1549 let shape = annotation::Shape::Poly(vec![]);
1550 assert!(shape_to_pdf_rect(&shape, 300.0, 72.0).is_none());
1551 }
1552
1553 #[test]
1554 fn test_shape_to_pdf_rect_line() {
1555 use crate::annotation;
1556 let shape = annotation::Shape::Line(0, 0, 150, 150);
1557 let rect = shape_to_pdf_rect(&shape, 150.0, 72.0).unwrap();
1558 assert!((rect.0 - 0.0).abs() < 0.01);
1559 assert!((rect.2 - 72.0).abs() < 0.01);
1560 }
1561
1562 #[test]
1563 fn test_count_outline_items_empty() {
1564 let bookmarks: Vec<crate::djvu_document::DjVuBookmark> = vec![];
1565 assert_eq!(count_outline_items(&bookmarks), 0);
1566 }
1567
1568 #[test]
1569 fn test_count_outline_items_nested() {
1570 use crate::djvu_document::DjVuBookmark;
1571 let bookmarks = vec![DjVuBookmark {
1572 title: "Chapter 1".into(),
1573 url: "#1".into(),
1574 children: vec![
1575 DjVuBookmark {
1576 title: "Section 1.1".into(),
1577 url: "#2".into(),
1578 children: vec![],
1579 },
1580 DjVuBookmark {
1581 title: "Section 1.2".into(),
1582 url: "#3".into(),
1583 children: vec![],
1584 },
1585 ],
1586 }];
1587 assert_eq!(count_outline_items(&bookmarks), 3);
1588 }
1589
1590 fn assets_path() -> std::path::PathBuf {
1593 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1594 .join("references/djvujs/library/assets")
1595 }
1596
1597 fn load_doc(name: &str) -> crate::djvu_document::DjVuDocument {
1598 let data =
1599 std::fs::read(assets_path().join(name)).unwrap_or_else(|_| panic!("{name} must exist"));
1600 crate::djvu_document::DjVuDocument::parse(&data)
1601 .unwrap_or_else(|e| panic!("parse failed: {e}"))
1602 }
1603
1604 #[derive(Default)]
1605 struct RecordingObserver {
1606 progress: Vec<(usize, usize)>,
1607 cancel_after: Option<usize>,
1608 }
1609
1610 impl ExportObserver for RecordingObserver {
1611 fn on_progress(&mut self, done: usize, total: usize) {
1612 self.progress.push((done, total));
1613 }
1614
1615 fn cancelled(&self) -> bool {
1616 self.cancel_after
1617 .is_some_and(|after| self.progress.len() >= after)
1618 }
1619 }
1620
1621 fn load_fixture_doc(name: &str) -> crate::djvu_document::DjVuDocument {
1622 let data = std::fs::read(
1623 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1624 .join("tests/fixtures")
1625 .join(name),
1626 )
1627 .unwrap();
1628 crate::djvu_document::DjVuDocument::parse(&data).unwrap()
1629 }
1630
1631 #[test]
1632 fn pdf_writer_observer_reports_each_page_in_order() {
1633 let doc = load_fixture_doc("vega.djvu");
1634 let total = doc.page_count();
1635 let mut observer = RecordingObserver::default();
1636
1637 djvu_to_pdf_to_writer_with_observer(
1638 &doc,
1639 &PdfOptions::default(),
1640 std::io::Cursor::new(Vec::new()),
1641 &mut observer,
1642 )
1643 .expect("observer export must succeed");
1644
1645 assert_eq!(
1646 observer.progress,
1647 (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
1648 );
1649 }
1650
1651 #[test]
1652 fn pdf_writer_cancellation_stops_after_completed_page() {
1653 let doc = load_fixture_doc("vega.djvu");
1654 assert!(doc.page_count() > 1, "fixture must contain multiple pages");
1655 let mut observer = RecordingObserver {
1656 cancel_after: Some(1),
1657 ..RecordingObserver::default()
1658 };
1659
1660 let error = djvu_to_pdf_to_writer_with_observer(
1661 &doc,
1662 &PdfOptions::default(),
1663 std::io::Cursor::new(Vec::new()),
1664 &mut observer,
1665 )
1666 .expect_err("observer must cancel the export");
1667
1668 assert!(matches!(error, PdfError::Cancelled));
1669 assert_eq!(observer.progress.len(), 1);
1670 }
1671
1672 #[test]
1673 fn pdf_default_writer_delegates_to_noop_observer() {
1674 let doc = load_fixture_doc("vega.djvu");
1675 let opts = PdfOptions::default();
1676
1677 let mut default_cursor = std::io::Cursor::new(Vec::new());
1678 djvu_to_pdf_to_writer(&doc, &opts, &mut default_cursor).unwrap();
1679
1680 let mut observed_cursor = std::io::Cursor::new(Vec::new());
1681 let mut observer = NoOpObserver;
1682 djvu_to_pdf_to_writer_with_observer(&doc, &opts, &mut observed_cursor, &mut observer)
1683 .unwrap();
1684
1685 assert_eq!(observed_cursor.into_inner(), default_cursor.into_inner());
1686 }
1687
1688 #[test]
1689 fn pdf_writer_failing_sink_returns_io_error() {
1690 let doc = load_fixture_doc("chicken.djvu");
1691 let error = djvu_to_pdf_to_writer(
1692 &doc,
1693 &PdfOptions::default(),
1694 crate::export_test_support::FailingWriter::after(2),
1695 )
1696 .expect_err("injected sink failure must be returned");
1697
1698 assert!(matches!(error, PdfError::Io(error) if error.kind() == std::io::ErrorKind::Other));
1699 }
1700
1701 #[test]
1702 #[ignore = "renders 100 synthetic pages to exercise the streaming sink path"]
1703 fn large_synthetic_export_streams_through_counting_sink() {
1704 const PAGE_COUNT: usize = 100;
1705
1706 let component = std::fs::read(
1707 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1708 .join("tests/fixtures/chicken.djvu"),
1709 )
1710 .expect("read source page");
1711 let mut bundle =
1712 crate::djvm::DjvmStreamWriter::new(Vec::new(), crate::djvm::DjvmSpool::Memory)
1713 .expect("create synthetic bundle writer");
1714 for page in 0..PAGE_COUNT {
1715 bundle
1716 .add_component(&format!("page_{page:04}.djvu"), 1, &component)
1717 .expect("add synthetic page");
1718 }
1719 let bundled = bundle.finish().expect("finish synthetic bundle");
1720 let doc =
1721 crate::djvu_document::DjVuDocument::parse(&bundled).expect("parse synthetic bundle");
1722 let mut observer = RecordingObserver::default();
1723 let mut sink = crate::export_test_support::CountingWriter::default();
1724
1725 djvu_to_pdf_to_writer_with_observer(&doc, &PdfOptions::default(), &mut sink, &mut observer)
1726 .expect("stream synthetic export into counting sink");
1727
1728 assert!(
1729 sink.bytes_written() > 0,
1730 "counting sink must receive output"
1731 );
1732 assert_eq!(
1733 observer.progress,
1734 (1..=PAGE_COUNT)
1735 .map(|done| (done, PAGE_COUNT))
1736 .collect::<Vec<_>>(),
1737 "every synthetic page must complete before the export returns"
1738 );
1739 }
1740
1741 #[test]
1743 fn pdf_options_default_is_jpeg80() {
1744 let opts = PdfOptions::default();
1745 assert_eq!(opts.jpeg_quality, Some(80));
1746 }
1747
1748 #[test]
1750 fn encode_rgb_to_jpeg_returns_jpeg() {
1751 let rgb = [255u8, 0, 0].repeat(16); let jpeg = encode_rgb_to_jpeg(&rgb, 4, 4, 80);
1754 assert!(!jpeg.is_empty(), "JPEG output must not be empty");
1755 assert_eq!(jpeg[0], 0xFF);
1757 assert_eq!(jpeg[1], 0xD8);
1758 }
1759
1760 #[test]
1762 fn make_dct_stream_has_dctdecode_filter() {
1763 let fake_jpeg = b"\xFF\xD8\xFF\xD9"; let stream = make_dct_stream(" /Type /XObject", fake_jpeg);
1765 let s = String::from_utf8_lossy(&stream);
1766 assert!(
1767 s.contains("/Filter /DCTDecode"),
1768 "must contain DCTDecode filter"
1769 );
1770 assert!(s.contains("/Type /XObject"));
1771 }
1772
1773 #[test]
1775 fn dct_pdf_is_smaller_than_deflate_pdf() {
1776 let doc = load_doc("chicken.djvu");
1777 let dct_pdf = djvu_to_pdf_with_options(
1778 &doc,
1779 &PdfOptions {
1780 jpeg_quality: Some(75),
1781 output_dpi: 150,
1782 adaptive_raster: false,
1783 ccitt_g4: false,
1784 mrc: false,
1785 },
1786 )
1787 .expect("DCT conversion must succeed");
1788 let flat_pdf = djvu_to_pdf_with_options(
1789 &doc,
1790 &PdfOptions {
1791 jpeg_quality: None,
1792 output_dpi: 150,
1793 adaptive_raster: false,
1794 ccitt_g4: false,
1795 mrc: false,
1796 },
1797 )
1798 .expect("FlateDecode conversion must succeed");
1799 assert!(
1800 dct_pdf.len() < flat_pdf.len(),
1801 "DCT PDF ({} bytes) must be smaller than FlateDecode PDF ({} bytes)",
1802 dct_pdf.len(),
1803 flat_pdf.len()
1804 );
1805 }
1806
1807 #[test]
1809 fn pdf_with_dct_contains_dctdecode_marker() {
1810 let doc = load_doc("chicken.djvu");
1811 let pdf = djvu_to_pdf_with_options(
1812 &doc,
1813 &PdfOptions {
1814 jpeg_quality: Some(80),
1815 output_dpi: 150,
1816 adaptive_raster: false,
1817 ccitt_g4: false,
1818 mrc: false,
1819 },
1820 )
1821 .unwrap();
1822 let has_dct = pdf.windows(9).any(|w| w == b"DCTDecode");
1823 assert!(has_dct, "PDF must contain DCTDecode");
1824 }
1825
1826 #[test]
1828 fn pdf_without_dct_has_no_dctdecode() {
1829 let doc = load_doc("chicken.djvu");
1830 let pdf = djvu_to_pdf_with_options(
1831 &doc,
1832 &PdfOptions {
1833 jpeg_quality: None,
1834 output_dpi: 150,
1835 adaptive_raster: false,
1836 ccitt_g4: false,
1837 mrc: false,
1838 },
1839 )
1840 .unwrap();
1841 let has_dct = pdf.windows(9).any(|w| w == b"DCTDecode");
1842 assert!(!has_dct, "FlateDecode PDF must not contain DCTDecode");
1843 }
1844
1845 #[test]
1847 fn default_djvu_to_pdf_is_dct() {
1848 let doc = load_doc("chicken.djvu");
1849 let default_pdf = djvu_to_pdf(&doc).unwrap();
1850 let flat_pdf = djvu_to_pdf_with_options(
1851 &doc,
1852 &PdfOptions {
1853 jpeg_quality: None,
1854 output_dpi: 150,
1855 adaptive_raster: false,
1856 ccitt_g4: false,
1857 mrc: false,
1858 },
1859 )
1860 .unwrap();
1861 assert!(
1862 default_pdf.len() < flat_pdf.len(),
1863 "default PDF must use DCT and be smaller than FlateDecode"
1864 );
1865 }
1866
1867 #[test]
1870 fn adaptive_raster_defaults_to_off() {
1871 assert!(!PdfOptions::default().adaptive_raster);
1872 assert!(!PdfOptions::archival().adaptive_raster);
1873 }
1874
1875 #[test]
1878 fn adaptive_raster_off_is_byte_identical_to_default() {
1879 let doc = load_doc("chicken.djvu");
1880 let plain = djvu_to_pdf(&doc).unwrap();
1881 let explicit_off = djvu_to_pdf_with_options(
1882 &doc,
1883 &PdfOptions {
1884 adaptive_raster: false,
1885 ..PdfOptions::default()
1886 },
1887 )
1888 .unwrap();
1889 assert_eq!(plain, explicit_off);
1890 }
1891
1892 #[test]
1896 fn adaptive_raster_shrinks_flat_colour_scan() {
1897 let data = std::fs::read(
1898 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1899 .join("tests/corpus/watchmaker.djvu"),
1900 )
1901 .expect("watchmaker.djvu must exist");
1902 let doc = crate::djvu_document::DjVuDocument::parse(&data).expect("parse");
1903
1904 let default_pdf = djvu_to_pdf(&doc).unwrap();
1905 let adaptive_pdf = djvu_to_pdf_with_options(
1906 &doc,
1907 &PdfOptions {
1908 adaptive_raster: true,
1909 ..PdfOptions::default()
1910 },
1911 )
1912 .unwrap();
1913
1914 assert!(
1915 adaptive_pdf.len() < default_pdf.len(),
1916 "adaptive PDF ({} B) must be smaller than always-DCT default ({} B)",
1917 adaptive_pdf.len(),
1918 default_pdf.len()
1919 );
1920 assert!(
1923 (default_pdf.len() as f64) / (adaptive_pdf.len() as f64) > 1.3,
1924 "expected a large win from adaptive raster on a flat colour scan"
1925 );
1926 }
1927
1928 #[test]
1931 fn adaptive_raster_never_larger_than_default() {
1932 let doc = load_doc("chicken.djvu");
1933 let default_pdf = djvu_to_pdf(&doc).unwrap();
1934 let adaptive_pdf = djvu_to_pdf_with_options(
1935 &doc,
1936 &PdfOptions {
1937 adaptive_raster: true,
1938 ..PdfOptions::default()
1939 },
1940 )
1941 .unwrap();
1942 assert!(adaptive_pdf.len() <= default_pdf.len());
1943 }
1944
1945 #[test]
1948 fn ccitt_g4_defaults_to_off() {
1949 assert!(!PdfOptions::default().ccitt_g4);
1950 assert!(!PdfOptions::archival().ccitt_g4);
1951 }
1952
1953 #[test]
1956 fn ccitt_g4_off_is_byte_identical_to_default() {
1957 let doc = load_doc("boy_jb2.djvu"); let plain = djvu_to_pdf(&doc).unwrap();
1959 let explicit_off = djvu_to_pdf_with_options(
1960 &doc,
1961 &PdfOptions {
1962 ccitt_g4: false,
1963 ..PdfOptions::default()
1964 },
1965 )
1966 .unwrap();
1967 assert_eq!(plain, explicit_off);
1968 }
1969
1970 #[test]
1974 fn ccitt_g4_on_uses_ccittfaxdecode_and_never_larger() {
1975 let doc = load_doc("boy_jb2.djvu");
1976 let default_pdf = djvu_to_pdf(&doc).unwrap();
1977 let g4_pdf = djvu_to_pdf_with_options(
1978 &doc,
1979 &PdfOptions {
1980 ccitt_g4: true,
1981 ..PdfOptions::default()
1982 },
1983 )
1984 .unwrap();
1985 let has_ccitt = g4_pdf.windows(14).any(|w| w == b"CCITTFaxDecode");
1986 assert!(has_ccitt, "ccitt_g4 PDF must contain /CCITTFaxDecode");
1987 assert!(
1988 g4_pdf.len() <= default_pdf.len(),
1989 "g4 PDF ({} B) must not be larger than default ({} B)",
1990 g4_pdf.len(),
1991 default_pdf.len()
1992 );
1993 }
1994
1995 #[test]
1999 fn ccitt_g4_shrinks_bilevel_corpus_doc() {
2000 let data = std::fs::read(
2001 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2002 .join("tests/corpus/watchmaker.djvu"),
2003 )
2004 .expect("watchmaker.djvu must exist");
2005 let doc = crate::djvu_document::DjVuDocument::parse(&data).expect("parse");
2006
2007 let default_pdf = djvu_to_pdf(&doc).unwrap();
2008 let g4_pdf = djvu_to_pdf_with_options(
2009 &doc,
2010 &PdfOptions {
2011 ccitt_g4: true,
2012 ..PdfOptions::default()
2013 },
2014 )
2015 .unwrap();
2016
2017 assert!(
2018 g4_pdf.len() < default_pdf.len(),
2019 "g4 PDF ({} B) must be smaller than Deflate-only default ({} B)",
2020 g4_pdf.len(),
2021 default_pdf.len()
2022 );
2023 }
2024
2025 #[test]
2028 fn collect_mask_stream_g4_returns_none_for_no_sjbz() {
2029 let doc = load_doc("chicken.djvu"); let page = doc.page(0).unwrap();
2031 let opts = PdfOptions {
2032 ccitt_g4: true,
2033 ..PdfOptions::default()
2034 };
2035 assert!(collect_mask_stream(page, &opts).is_none());
2036 }
2037
2038 #[test]
2039 fn pdf_rgb_streaming_matches_pixmap_rgb() {
2040 let doc = load_doc("boy.djvu");
2041 let page = doc.page(0).unwrap();
2042 let (rw, rh) = render_dims(page, PdfOptions::default().output_dpi);
2043 let opts = RenderOptions {
2044 width: rw,
2045 height: rh,
2046 ..RenderOptions::default()
2047 };
2048
2049 let streamed = render_rgb_for_pdf(page, &opts, rw, rh).unwrap();
2050 let pixmap = djvu_render::render_pixmap(page, &opts).unwrap();
2051
2052 assert_eq!(streamed, pixmap.to_rgb());
2053 }
2054
2055 #[test]
2056 fn pdf_rgb_fallback_handles_non_streamable_options() {
2057 let doc = load_doc("boy.djvu");
2058 let page = doc.page(0).unwrap();
2059 let opts = RenderOptions {
2060 width: page.width() as u32,
2061 height: page.height() as u32,
2062 aa: true,
2063 ..RenderOptions::default()
2064 };
2065
2066 let rgb = render_rgb_for_pdf(page, &opts, opts.width, opts.height).unwrap();
2067 let pixmap = djvu_render::render_pixmap(page, &opts).unwrap();
2068
2069 assert_eq!(rgb, pixmap.to_rgb());
2070 }
2071
2072 #[test]
2076 fn pdf_with_text_layer_contains_bt_et_markers() {
2077 let doc = load_doc("colorbook.djvu");
2078 let pdf = djvu_to_pdf(&doc).unwrap();
2079 assert!(!pdf.is_empty(), "PDF must not be empty");
2084 let has_font = pdf.windows(5).any(|w| w == b"/Font");
2086 assert!(
2087 has_font,
2088 "PDF with text layer must reference a /Font resource"
2089 );
2090 }
2091
2092 #[test]
2094 fn build_text_content_no_text_layer_returns_empty() {
2095 let doc = load_doc("chicken.djvu"); let page = doc.page(0).unwrap();
2097 let result = build_text_content(page, 100.0, 720.0);
2098 assert!(
2099 result.is_empty(),
2100 "page without text layer must produce empty text content"
2101 );
2102 }
2103
2104 #[test]
2106 fn build_text_content_with_text_layer_returns_non_empty() {
2107 let doc = load_doc("colorbook.djvu"); for i in 0..doc.page_count() {
2110 let page = doc.page(i).unwrap();
2111 if page.text_layer().ok().flatten().is_some() {
2112 let dpi = page.dpi().max(1) as f32;
2113 let pt_h = page.height() as f32 * 72.0 / dpi;
2114 let result = build_text_content(page, dpi, pt_h);
2115 if !result.is_empty() {
2116 assert!(result.contains("BT"), "text content must begin with BT");
2117 assert!(result.contains("ET"), "text content must end with ET");
2118 return;
2119 }
2120 }
2121 }
2122 }
2124
2125 #[test]
2129 fn pdf_with_bookmarks_contains_outlines() {
2130 let doc = load_doc("links.djvu"); let pdf = djvu_to_pdf(&doc).unwrap();
2132 let has_outlines = pdf.windows(8).any(|w| w == b"Outlines");
2133 assert!(
2134 has_outlines,
2135 "PDF with NAVM bookmarks must contain /Outlines"
2136 );
2137 }
2138
2139 #[test]
2141 fn pdf_without_bookmarks_has_no_outlines() {
2142 let doc = load_doc("chicken.djvu"); let pdf = djvu_to_pdf(&doc).unwrap();
2144 let has_outlines = pdf.windows(8).any(|w| w == b"Outlines");
2145 assert!(
2146 !has_outlines,
2147 "PDF without bookmarks must not contain /Outlines"
2148 );
2149 }
2150
2151 #[test]
2154 fn resolve_bookmark_dest_page_anchor() {
2155 let page_ids = [10usize, 20, 30];
2156 let dest = resolve_bookmark_dest("#page_1", &page_ids);
2158 assert!(dest.contains("/Dest"), "must produce /Dest: {dest}");
2159 assert!(dest.contains("10 0 R"), "must reference page id 10: {dest}");
2160 let dest2 = resolve_bookmark_dest("#page_2", &page_ids);
2162 assert!(
2163 dest2.contains("20 0 R"),
2164 "must reference page id 20: {dest2}"
2165 );
2166 }
2167
2168 #[test]
2170 fn resolve_bookmark_dest_external_url() {
2171 let dest = resolve_bookmark_dest("https://example.com", &[10, 20]);
2172 assert!(
2173 dest.contains("/URI"),
2174 "external URL must produce URI action: {dest}"
2175 );
2176 }
2177
2178 #[test]
2180 fn resolve_bookmark_dest_empty_url() {
2181 let dest = resolve_bookmark_dest("", &[10]);
2182 assert!(dest.is_empty(), "empty URL must produce empty dest: {dest}");
2183 }
2184
2185 #[test]
2189 fn collect_link_annot_bodies_runs_without_error() {
2190 let doc = load_doc("czech.djvu"); for i in 0..doc.page_count() {
2192 let page = doc.page(i).unwrap();
2193 let dpi = page.dpi().max(1) as f32;
2194 let pt_h = page.height() as f32 * 72.0 / dpi;
2195 let _ = collect_link_annot_bodies(page, dpi, pt_h);
2196 }
2197 }
2199
2200 #[test]
2205 fn link_annot_body_format() {
2206 use crate::annotation::{MapArea, Rect as ARect, Shape};
2207
2208 let link = MapArea {
2210 shape: Shape::Rect(ARect {
2211 x: 10,
2212 y: 20,
2213 width: 100,
2214 height: 50,
2215 }),
2216 url: "https://example.com".to_string(),
2217 description: String::new(),
2218 border: None,
2219 highlight: None,
2220 };
2221
2222 let rect = shape_to_pdf_rect(&link.shape, 100.0, 360.0);
2223 assert!(rect.is_some(), "Rect shape must produce a PDF rect");
2224 let (x1, y1, x2, y2) = rect.unwrap();
2225 let url_escaped = pdf_escape_string(&link.url);
2226 let body = format!(
2227 "<< /Type /Annot /Subtype /Link\n\
2228 /Rect [{:.4} {:.4} {:.4} {:.4}]\n\
2229 /Border [0 0 0]\n\
2230 /A << /S /URI /URI ({url_escaped}) >> >>",
2231 x1, y1, x2, y2
2232 );
2233 assert!(body.contains("/Type /Annot"), "must have /Type /Annot");
2234 assert!(body.contains("/Subtype /Link"), "must have /Subtype /Link");
2235 assert!(body.contains("https://example.com"), "must contain URL");
2236 assert!(body.contains("/Rect"), "must have /Rect");
2237 }
2238
2239 #[test]
2243 fn bilevel_only_page_has_image_mask() {
2244 let doc = load_doc("boy_jb2.djvu"); let pdf = djvu_to_pdf(&doc).unwrap();
2246 let has_mask = pdf.windows(9).any(|w| w == b"ImageMask");
2247 assert!(has_mask, "bilevel-only page must embed /ImageMask XObject");
2248 }
2249
2250 #[test]
2256 fn mixed_page_has_both_image_and_mask_xobject() {
2257 let doc = load_doc("irish.djvu"); let pdf = djvu_to_pdf(&doc).unwrap();
2259 let has_im0 = pdf.windows(4).any(|w| w == b"Im0 ");
2260 let has_mask0 = pdf.windows(5).any(|w| w == b"Mask0");
2261 assert!(has_im0, "mixed page must reference /Im0 background");
2262 assert!(
2263 has_mask0,
2264 "mixed page must reference /Mask0 foreground mask"
2265 );
2266 }
2267
2268 #[test]
2272 fn fg44_page_skips_mask_stencil() {
2273 let doc = load_doc("colorbook.djvu"); let pdf = djvu_to_pdf(&doc).unwrap();
2275 let has_im0 = pdf.windows(4).any(|w| w == b"Im0 ");
2276 let has_mask0 = pdf.windows(5).any(|w| w == b"Mask0");
2277 assert!(has_im0, "FG44 page must reference /Im0 background");
2278 assert!(
2279 !has_mask0,
2280 "FG44 page must not paint a flat stencil over continuous-tone text"
2281 );
2282 }
2283
2284 #[test]
2288 fn lower_output_dpi_produces_smaller_pdf() {
2289 let doc = load_doc("chicken.djvu");
2290 let native = djvu_to_pdf_with_options(
2291 &doc,
2292 &PdfOptions {
2293 jpeg_quality: None,
2294 output_dpi: 0,
2295 adaptive_raster: false,
2296 ccitt_g4: false,
2297 mrc: false,
2298 },
2299 )
2300 .unwrap();
2301 let downscaled = djvu_to_pdf_with_options(
2302 &doc,
2303 &PdfOptions {
2304 jpeg_quality: None,
2305 output_dpi: 50,
2306 adaptive_raster: false,
2307 ccitt_g4: false,
2308 mrc: false,
2309 },
2310 )
2311 .unwrap();
2312 assert!(
2313 downscaled.len() < native.len(),
2314 "50 DPI PDF ({} B) must be smaller than native ({} B)",
2315 downscaled.len(),
2316 native.len()
2317 );
2318 }
2319
2320 #[test]
2323 fn pdf_archival_preset_produces_output() {
2324 let doc = load_doc("chicken.djvu");
2325 let pdf = djvu_to_pdf_with_options(&doc, &PdfOptions::archival()).unwrap();
2326 assert!(!pdf.is_empty());
2327 assert!(
2328 pdf.starts_with(b"%PDF-"),
2329 "archival PDF must start with %PDF-"
2330 );
2331 }
2332
2333 #[test]
2334 fn pdf_archival_preset_fields() {
2335 let opts = PdfOptions::archival();
2336 assert_eq!(opts.jpeg_quality, Some(90));
2337 assert_eq!(opts.output_dpi, 0);
2338 }
2339
2340 #[test]
2343 fn pdf_escape_parens_and_backslash() {
2344 assert_eq!(pdf_escape_string("a(b)c"), "a\\(b\\)c");
2345 assert_eq!(pdf_escape_string("a\\b"), "a\\\\b");
2346 }
2347
2348 #[test]
2349 fn pdf_escape_ascii_passthrough() {
2350 assert_eq!(pdf_escape_string("hello 123"), "hello 123");
2351 }
2352
2353 #[test]
2354 fn pdf_escape_non_ascii_replaced_with_question_mark() {
2355 let s = pdf_escape_string("über");
2356 assert!(s.contains('?'), "non-ASCII must be replaced with ?: {s}");
2357 }
2358
2359 #[test]
2362 fn helvetica_advance_space() {
2363 assert!((helvetica_advance(' ') - 0.278).abs() < 1e-6);
2364 }
2365
2366 #[test]
2367 fn helvetica_advance_digit() {
2368 assert!((helvetica_advance('5') - 0.556).abs() < 1e-6);
2369 }
2370
2371 #[test]
2372 fn helvetica_advance_uppercase() {
2373 assert!((helvetica_advance('A') - 0.667).abs() < 1e-6);
2374 }
2375
2376 #[test]
2377 fn helvetica_advance_lowercase() {
2378 assert!((helvetica_advance('a') - 0.556).abs() < 1e-6);
2379 }
2380
2381 #[test]
2382 fn helvetica_advance_cjk_full_width() {
2383 assert!((helvetica_advance('中') - 1.0).abs() < 1e-6);
2385 }
2386
2387 #[test]
2388 fn helvetica_advance_hiragana_full_width() {
2389 assert!((helvetica_advance('あ') - 1.0).abs() < 1e-6);
2390 }
2391
2392 #[test]
2393 fn helvetica_advance_cyrillic_falls_back() {
2394 assert!((helvetica_advance('А') - 0.556).abs() < 1e-6);
2396 }
2397
2398 #[test]
2399 fn helvetica_advance_control_char_is_zero() {
2400 assert!((helvetica_advance('\x00') - 0.0).abs() < 1e-6);
2401 assert!((helvetica_advance('\x7f') - 0.0).abs() < 1e-6);
2402 }
2403
2404 #[test]
2405 fn helvetica_advance_punctuation() {
2406 assert!((helvetica_advance(',') - 0.278).abs() < 1e-6);
2407 assert!((helvetica_advance('(') - 0.333).abs() < 1e-6);
2408 assert!((helvetica_advance('-') - 0.333).abs() < 1e-6);
2409 }
2410
2411 #[test]
2414 fn collect_mask_stream_returns_none_for_no_sjbz() {
2415 let doc = load_doc("chicken.djvu"); let page = doc.page(0).unwrap();
2417 let result = collect_mask_stream(page, &PdfOptions::default());
2418 assert!(
2419 result.is_none(),
2420 "page without Sjbz must return None from collect_mask_stream"
2421 );
2422 }
2423
2424 #[test]
2425 fn collect_mask_stream_returns_some_for_sjbz_page() {
2426 let doc = load_doc("boy_jb2.djvu"); let page = doc.page(0).unwrap();
2428 let result = collect_mask_stream(page, &PdfOptions::default());
2429 assert!(
2430 result.is_some(),
2431 "page with Sjbz must return Some from collect_mask_stream"
2432 );
2433 let body = result.unwrap();
2434 assert!(
2436 body.windows(9).any(|w| w == b"ImageMask"),
2437 "mask stream must contain /ImageMask"
2438 );
2439 }
2440
2441 #[test]
2444 fn shape_to_pdf_rect_converts_rect_shape() {
2445 use crate::annotation::{Rect as ARect, Shape};
2446 let shape = Shape::Rect(ARect {
2447 x: 0,
2448 y: 0,
2449 width: 100,
2450 height: 50,
2451 });
2452 let rect = shape_to_pdf_rect(&shape, 100.0, 360.0);
2453 assert!(rect.is_some(), "valid rect shape must produce a PDF rect");
2454 let (x1, y1, x2, y2) = rect.unwrap();
2455 assert!((x1 - 0.0).abs() < 0.01);
2456 assert!((y1 - 0.0).abs() < 0.01);
2457 assert!((x2 - 72.0).abs() < 0.01); assert!((y2 - 36.0).abs() < 0.01); }
2460
2461 #[test]
2464 fn px_to_pt_at_72dpi_is_identity() {
2465 assert!((px_to_pt(100.0, 72.0) - 100.0).abs() < 0.001);
2466 }
2467
2468 #[test]
2469 fn px_to_pt_at_300dpi() {
2470 assert!((px_to_pt(300.0, 300.0) - 72.0).abs() < 0.001);
2472 }
2473
2474 #[test]
2477 fn emit_word_span_zero_width_produces_no_ops() {
2478 use crate::text::Rect;
2479 let rect = Rect {
2480 x: 0,
2481 y: 0,
2482 width: 0,
2483 height: 20,
2484 };
2485 let mut ops = String::new();
2486 emit_word_span(&mut ops, &rect, "hello", 72.0, 720.0);
2487 assert!(ops.is_empty(), "zero-width rect must produce no output");
2488 }
2489
2490 #[test]
2491 fn emit_word_span_tiny_height_produces_no_ops() {
2492 use crate::text::Rect;
2493 let rect = Rect {
2495 x: 0,
2496 y: 0,
2497 width: 50,
2498 height: 1,
2499 };
2500 let mut ops = String::new();
2501 emit_word_span(&mut ops, &rect, "hi", 300.0, 720.0);
2502 assert!(ops.is_empty(), "sub-0.5pt font size must produce no output");
2503 }
2504
2505 #[test]
2508 fn build_outline_with_nested_children_sets_first_last_count() {
2509 use crate::djvu_document::DjVuBookmark;
2510 let bookmarks = vec![DjVuBookmark {
2511 title: "Chapter 1".into(),
2512 url: "#page_1".into(),
2513 children: vec![
2514 DjVuBookmark {
2515 title: "Section 1.1".into(),
2516 url: "#page_2".into(),
2517 children: vec![],
2518 },
2519 DjVuBookmark {
2520 title: "Section 1.2".into(),
2521 url: "#page_3".into(),
2522 children: vec![],
2523 },
2524 ],
2525 }];
2526 let page_ids = [10usize, 20, 30];
2527 let mut pdf = Vec::new();
2528 let mut w = PdfWriter::new(&mut pdf).unwrap();
2529 let outline_id = build_outline(&mut w, &bookmarks, &page_ids).unwrap();
2530 assert!(
2531 outline_id.is_some(),
2532 "nested bookmarks must produce an outline"
2533 );
2534 w.finish().unwrap();
2536 let s = String::from_utf8_lossy(&pdf);
2537 assert!(
2538 s.contains("/First"),
2539 "outline item with children must set /First"
2540 );
2541 assert!(
2542 s.contains("/Last"),
2543 "outline item with children must set /Last"
2544 );
2545 assert!(
2546 s.contains("/Count"),
2547 "outline item with children must set /Count"
2548 );
2549 }
2550
2551 #[test]
2554 fn djvu_to_pdf_with_hyperlinks_produces_annots() {
2555 use crate::annotation::{self as ann, Annotation, MapArea};
2556 use crate::djvu_document::DjVuDocument;
2557 use crate::iff::{self as iff_mod, Chunk, DjvuFile};
2558 let maparea = MapArea {
2559 url: "https://example.com".to_string(),
2560 description: String::new(),
2561 shape: ann::Shape::Rect(ann::Rect {
2562 x: 0,
2563 y: 0,
2564 width: 100,
2565 height: 50,
2566 }),
2567 border: None,
2568 highlight: None,
2569 };
2570 let ant_data = ann::encode_annotations_bzz(&Annotation::default(), &[maparea]);
2571 let mut info = vec![0u8; 10];
2573 info[1] = 100; info[3] = 100; let bytes = iff_mod::emit(&DjvuFile {
2576 root: Chunk::Form {
2577 secondary_id: *b"DJVU",
2578 length: 0,
2579 children: vec![
2580 Chunk::Leaf {
2581 id: *b"INFO",
2582 data: info,
2583 },
2584 Chunk::Leaf {
2585 id: *b"ANTz",
2586 data: ant_data,
2587 },
2588 ],
2589 },
2590 });
2591 let doc = DjVuDocument::parse(&bytes).expect("synthetic doc must parse");
2592 let pdf = djvu_to_pdf(&doc).expect("synthetic hyperlink page must convert to PDF");
2593 let s = String::from_utf8_lossy(&pdf);
2594 assert!(
2595 s.contains("/Annots"),
2596 "PDF from hyperlink page must contain /Annots"
2597 );
2598 }
2599
2600 #[test]
2603 fn djvu_to_pdf_with_corrupted_antz_skips_annotations() {
2604 use crate::djvu_document::DjVuDocument;
2605 use crate::iff::{self as iff_mod, Chunk, DjvuFile};
2606
2607 let mut info = vec![0u8; 10];
2608 info[1] = 100; info[3] = 100; let bad_antz: Vec<u8> = vec![0xFF, 0xFE, 0xAB, 0xCD, 0x12, 0x34];
2612 let bytes = iff_mod::emit(&DjvuFile {
2613 root: Chunk::Form {
2614 secondary_id: *b"DJVU",
2615 length: 0,
2616 children: vec![
2617 Chunk::Leaf {
2618 id: *b"INFO",
2619 data: info,
2620 },
2621 Chunk::Leaf {
2622 id: *b"ANTz",
2623 data: bad_antz,
2624 },
2625 ],
2626 },
2627 });
2628 let doc = DjVuDocument::parse(&bytes).expect("synthetic doc must parse");
2629 let pdf = djvu_to_pdf(&doc).expect("corrupted ANTz must not abort PDF export");
2630 let s = String::from_utf8_lossy(&pdf);
2631 assert!(
2632 !s.contains("/Annots"),
2633 "corrupted ANTz should produce no /Annots block"
2634 );
2635 }
2636
2637 #[test]
2641 fn djvu_to_pdf_zero_dim_page_emits_blank_page_object() {
2642 use crate::djvu_document::DjVuDocument;
2643 use crate::iff::{self as iff_mod, Chunk, DjvuFile};
2644
2645 let info = vec![0u8; 10];
2650 let bytes = iff_mod::emit(&DjvuFile {
2651 root: Chunk::Form {
2652 secondary_id: *b"DJVU",
2653 length: 0,
2654 children: vec![Chunk::Leaf {
2655 id: *b"INFO",
2656 data: info,
2657 }],
2658 },
2659 });
2660 let doc = DjVuDocument::parse(&bytes).expect("zero-dim doc must parse");
2661 let pdf = djvu_to_pdf(&doc).expect("zero-dim page must not crash PDF export");
2662 let s = String::from_utf8_lossy(&pdf);
2663 assert!(
2664 s.contains("/Type /Page"),
2665 "PDF must contain at least one Page object"
2666 );
2667 }
2668}