Skip to main content

djvu_rs/
pdf.rs

1//! DjVu to PDF converter — preserves document structure.
2//!
3//! Converts DjVu documents to PDF while preserving:
4//! - IW44 background as compressed RGB image (#2)
5//! - JB2 foreground mask as 1-bit image (#3)
6//! - Text layer as invisible selectable text (#4)
7//! - NAVM bookmarks as PDF outline / table of contents (#5)
8//! - ANTz hyperlinks as PDF link annotations (#6)
9//!
10//! # Example
11//!
12//! ```no_run
13//! use djvu_rs::djvu_document::DjVuDocument;
14//! use djvu_rs::pdf::djvu_to_pdf;
15//!
16//! let data = std::fs::read("input.djvu").unwrap();
17//! let doc = DjVuDocument::parse(&data).unwrap();
18//! let pdf_bytes = djvu_to_pdf(&doc).unwrap();
19//! std::fs::write("output.pdf", pdf_bytes).unwrap();
20//! ```
21
22#[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// ---- Error ------------------------------------------------------------------
36
37/// Errors from PDF conversion.
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum PdfError {
41    /// Document model error.
42    #[error("document error: {0}")]
43    Doc(#[from] DocError),
44    /// Render error.
45    #[error("render error: {0}")]
46    Render(#[from] djvu_render::RenderError),
47    /// I/O error writing to the output sink (`djvu_to_pdf_to_writer`).
48    #[error("io error: {0}")]
49    Io(#[from] std::io::Error),
50    /// Export was cancelled by its observer.
51    #[error("export cancelled")]
52    Cancelled,
53}
54
55// ---- Low-level PDF object writer --------------------------------------------
56
57/// Streams PDF objects to a [`Write`](std::io::Write) sink as they are added,
58/// retaining only `(id, byte offset)` per object for the final xref table
59/// (#606). Object bodies are written in insertion order — the same order the
60/// former buffer-everything writer serialized them in, so output bytes are
61/// unchanged.
62struct PdfWriter<W: std::io::Write> {
63    sink: W,
64    /// Bytes written so far (= next object's offset).
65    written: usize,
66    /// `(object id, byte offset)` in insertion order.
67    offsets: Vec<(usize, usize)>,
68    next_id: usize,
69}
70
71impl<W: std::io::Write> PdfWriter<W> {
72    /// Create the writer and emit the PDF header.
73    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    /// Reserve the next object ID.
91    fn alloc_id(&mut self) -> usize {
92        let id = self.next_id;
93        self.next_id += 1;
94        id
95    }
96
97    /// Write an object with a pre-allocated ID; its body is not retained.
98    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    /// Allocate and write an object, returning its ID.
106    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    /// Write the cross-reference table and trailer, consuming the writer.
113    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
148/// Helper: make a PDF stream object `<< ... /Length N >> stream\n...\nendstream`.
149fn 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
157/// Compress bytes using zlib/deflate.
158fn deflate(data: &[u8]) -> Vec<u8> {
159    miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
160}
161
162/// Helper: make a compressed stream object.
163fn 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
169/// Encode RGB bytes as JPEG and return the compressed bytes.
170///
171/// `quality` is in range 1–100. Values around 75–85 give excellent
172/// perceptual quality for typical DjVu backgrounds at a fraction of the
173/// FlateDecode+RGB size.
174fn 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    // Ignore encoding errors — fallback to empty, which will be caught at
179    // the caller and downgraded to FlateDecode.
180    let _ = enc.encode(rgb, width as u16, height as u16, ColorType::Rgb);
181    out
182}
183
184/// Helper: make a DCTDecode (JPEG) stream object.
185fn 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
190/// Helper: make a CCITTFaxDecode (Group 4 / T.6) stream object.
191///
192/// `K -1` selects pure two-dimensional (G4) decoding. `BlackIs1 true` matches
193/// this crate's `Bitmap`/JB2 convention (bit `1` = black/marked pixel) so the
194/// decoded samples are byte-identical to what the Deflate path already embeds
195/// — only the filter changes, not the downstream `/Decode` array.
196fn 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
204// ---- PDF font for invisible text --------------------------------------------
205
206/// Build a Type1 font dictionary for Helvetica (standard 14 font, no embedding needed).
207/// Returns object body bytes.
208fn font_dict() -> Vec<u8> {
209    b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()
210}
211
212// ---- Coordinate helpers -----------------------------------------------------
213
214/// Convert DjVu pixel coordinates to PDF points.
215/// DjVu uses bottom-left origin (like PDF), so y-coordinates can be used directly
216/// after scaling by 72/dpi.
217fn px_to_pt(px: f32, dpi: f32) -> f32 {
218    px * 72.0 / dpi
219}
220
221// ---- Page rendering ---------------------------------------------------------
222
223/// Compute render dimensions for a page given `output_dpi` option.
224///
225/// Returns `(render_w, render_h)` in pixels. When `output_dpi == 0` the native
226/// page resolution is returned unchanged.
227fn render_dims(page: &DjVuPage, output_dpi: u32) -> (u32, u32) {
228    let native_dpi = page.dpi().max(1) as f32;
229    // PDF never upscales: a zero or above-native target DPI keeps native pixels.
230    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
236/// Pre-rendered page data — all expensive compute done, ready for sequential PDF emit.
237///
238/// # Memory note
239///
240/// `djvu_to_pdf_impl` collects `RenderedPage` for every page before emitting any PDF
241/// objects (because `PdfWriter` is not `Send`). For large bilevel documents at native
242/// DPI (e.g. 520 pages × ~1 MB deflated mask each) peak RAM can be significant.
243/// A streaming/chunked approach is tracked in a separate issue.
244struct RenderedPage {
245    pt_w: f32,
246    pt_h: f32,
247    is_bilevel_only: bool,
248    /// Fully encoded XObject body written as PDF resource `/Im0`.
249    ///
250    /// For bilevel-only pages this is the 1-bit JB2 mask; for mixed pages it is the
251    /// RGB background image.
252    img0_body: Option<Vec<u8>>,
253    /// Fully encoded XObject bodies for the JB2 mask overlay (`/Mask0`,
254    /// `/Mask1`, …), one per foreground colour, each painted in its own
255    /// fill colour. Only populated for non-bilevel pages with a Sjbz chunk;
256    /// pages without an FGbz colour palette get a single black layer.
257    mask_layers: Vec<MaskLayer>,
258    /// PDF content stream text operators (invisible text layer).
259    text_ops: String,
260    /// Pre-built annotation object bodies, one per hyperlink.
261    link_annot_bodies: Vec<Vec<u8>>,
262}
263
264/// Render one page into a [`RenderedPage`].
265///
266/// This is the expensive step (pixel render, JPEG encode, JB2 decode, deflate)
267/// and can safely run in parallel across pages.
268fn 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        // Bilevel fast path: embed the 1-bit JB2 mask as the sole XObject.
279        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        // True MRC (#563): the stencils fully cover the foreground, so embed
289        // the background layer alone at its native (subsampled) resolution —
290        // the page `cm` scales it to the MediaBox. Smaller (no upsampling, no
291        // glyph edges in the raster) and cleaner (no JPEG ringing halos).
292        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        // Set only the output size: the render pipeline derives the IW44 decode
301        // scale from `width` (see `RenderOptions::decode_scale`). Previously this
302        // left `scale = 1.0` at every DPI, forcing a full-resolution wavelet
303        // decode followed by a downscale (#377).
304        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
331/// Encode packed RGB rows into the `/Im0` XObject body per the raster policy
332/// (`jpeg_quality`, `adaptive_raster` — PDF_ADAPTIVE_RASTER's "encode both,
333/// keep smaller"; only one page's pair of encodings is live at once, #449).
334fn 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
373/// Decode and compress the JB2 foreground mask into a PDF ImageMask XObject body.
374///
375/// When `opts.ccitt_g4` is set (`PDF_G4`, opt-in), the mask is also encoded as
376/// CCITTFaxDecode (Group 4 / T.6) via [`crate::smmr::encode_g4`] and whichever
377/// stream is smaller is kept — the same "encode both, keep smaller" pattern as
378/// `adaptive_raster` (round 28), so enabling it can never regress a page's mask
379/// size. Default (`ccitt_g4: false`) is byte-identical to the pre-existing
380/// Deflate-only behaviour.
381///
382/// Decodes via [`DjVuPage::extract_mask`] so shared-dictionary (DJVI `Djbz`)
383/// pages get their mask too — the previous inline-Djbz-only decode silently
384/// dropped the whole foreground overlay for such documents (#620).
385fn 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
390/// Encode one 1-bit bitmap as a PDF ImageMask XObject body (Deflate, or the
391/// smaller of Deflate/G4 when `use_g4` is set).
392fn mask_body_from_bitmap(bitmap: &crate::bitmap::Bitmap, use_g4: bool) -> Vec<u8> {
393    let bw = bitmap.width;
394    let bh = bitmap.height;
395    // Bitmap data is already packed 1-bit MSB-first, which is what PDF expects
396    // for an ImageMask with /Decode [1 0] (1=black=marked).
397    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
414/// One foreground stencil layer: an ImageMask XObject body painted in `rgb`.
415///
416/// `bbox` is the layer's pixel bounding box `(x0, y0_top, bw, bh)` within the
417/// full mask of `mask_dims` pixels — colour planes are cropped to their
418/// bounding box before compression, so the content stream must scale and
419/// translate each stencil back into place.
420struct MaskLayer {
421    rgb: (u8, u8, u8),
422    bbox: (u32, u32, u32, u32),
423    mask_dims: (u32, u32),
424    body: Vec<u8>,
425}
426
427/// Build the foreground stencil layers for a mixed page.
428///
429/// Pages without an FGbz colour palette (or whose palette is entirely black)
430/// keep the historical single black stencil — byte-identical output. Pages with
431/// a non-black FGbz palette get one ImageMask per palette colour actually used,
432/// each painted in its own fill colour (#559: a single black stencil flattened
433/// coloured foreground text to black).
434fn 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            // FG44/FGjp foreground: the text colour is continuous-tone, so a
445            // flat black stencil can flatten it (the FG44 analogue of #559).
446            // If the FG44 colour under the mask is near-uniform (the common
447            // scanned-book case: near-black text), keep a single stencil
448            // painted in that colour — crisp full-res edges, correct colour.
449            // Otherwise skip the stencil and let the composited /Im0 carry the
450            // multi-coloured text (colour fidelity over edge crispness; true
451            // MRC stencilling of FG44 pages is #563).
452            if !page.fg44_chunks().is_empty() || page.find_chunk(b"FGjp").is_some() {
453                // `decoded_mask`/`decoded_fg44` hit the page cache — the
454                // page's own render (for /Im0) already decoded both layers,
455                // so the heuristic must not decode them a second time.
456                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        // Indexed decode failed — fall back to the black stencil path.
477        return black_mask_layer(page, opts);
478    };
479
480    // Pass 1: per-pixel colour index + per-colour bounding box. Colour lookup
481    // mirrors the renderer (`lookup_palette_color`): blit index → FGbz index
482    // table (or direct index when the table is absent) → colour, falling back
483    // to colour 0.
484    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    // (min_x, min_y, max_x, max_y) per colour
489    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    // Pass 2: one bilevel plane per used colour, cropped to its bounding box
520    // (a full-page plane per colour costs far more Deflate output — the crop
521    // is what keeps the multi-stencil overhead small).
522    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                // Colour planes are new output (no byte-identity to preserve),
550                // so always pick the smaller of Deflate/G4 regardless of the
551                // `ccitt_g4` opt-in — the per-plane min can never regress size.
552                body: mask_body_from_bitmap(&plane, true),
553            })
554        })
555        .collect()
556}
557
558/// The historical single black stencil (pages without a colour palette).
559fn black_mask_layer(page: &DjVuPage, opts: &PdfOptions) -> Vec<MaskLayer> {
560    // Prefer the page cache (populated by this page's own /Im0 render).
561    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
570/// A single full-mask stencil painted in `rgb`, from an already-decoded mask.
571fn 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
584/// Per-channel spread (max−min) above which the FG44 foreground colour under
585/// the mask counts as multi-coloured and the flat stencil is skipped.
586const FG44_UNIFORM_SPREAD: u8 = 48;
587
588/// The page's FG44/FGjp foreground colour, if near-uniform under the mask.
589///
590/// Samples the (subsampled) foreground pixmap at every marked mask pixel and
591/// returns the mean colour when every channel's spread stays within
592/// [`FG44_UNIFORM_SPREAD`]; `None` when the foreground is multi-coloured (or
593/// either layer fails to decode).
594fn 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
628/// Format one colour component for a PDF `rg` operator (0..255 → 0..1).
629///
630/// `0` and `255` format as the exact literals `0` / `1` so the all-black layer
631/// emits the historical `0 0 0 rg` operator byte-for-byte.
632fn 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
640/// Format a point offset for a `cm` operator: exact `0` for zero (matching the
641/// historical full-page operator), 4 decimals otherwise.
642fn fmt_pt(v: f32) -> String {
643    if v == 0.0 {
644        "0".to_string()
645    } else {
646        format!("{v:.4}")
647    }
648}
649
650/// Build pre-serialized annotation bodies for all hyperlinks on a page.
651fn 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
675/// Emit a pre-rendered page into `PdfWriter` (sequential). Returns the page object ID.
676fn 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        // img0 may still be None if JB2 decode failed at render time — render gracefully.
700        if img_id.is_some() {
701            // /Im0 is an ImageMask stencil: marked samples paint in the current
702            // fill colour, so it must be black. The historical `1 1 1 rg` here
703            // painted white-on-white — every bilevel-only page rendered blank
704            // (#621).
705            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            // Map the cropped stencil back into place: PDF images fill the unit
717            // square of the current transform, rows top-down, page origin
718            // bottom-left. A full-page bbox reproduces the historical
719            // `{pt_w} 0 0 {pt_h} 0 0 cm` operator byte-for-byte.
720            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
778/// Build invisible text operators for the text layer.
779fn 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    // Begin text object
787    ops.push_str("BT\n");
788    // Set text rendering mode to invisible (mode 3)
789    ops.push_str("3 Tr\n");
790    // Set font — use a small size, we scale per-word
791    ops.push_str("/F1 1 Tf\n");
792
793    // Emit one positioned run per leaf word/character zone (shared zone-walk).
794    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        // No actual text was emitted
802        return String::new();
803    }
804
805    ops
806}
807
808/// Emit text positioning operators for one leaf word/character span.
809///
810/// `rect` is top-left-origin pixels; PDF uses bottom-left origin, so the
811/// baseline is flipped in point space: `pdf_y = pt_h - (r.y + r.height) * 72/dpi`.
812/// (This subtract-after-convert order is what produces byte-identical output;
813/// see the note on [`crate::export_common::flip_y_bottom`].)
814fn 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    // Font size = zone height in points
825    let font_size = h;
826    if font_size < 0.5 {
827        return;
828    }
829
830    // Horizontal scale to fit text width
831    let text_escaped = pdf_escape_string(text);
832    // Sum per-character advance widths using Helvetica metrics.
833    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
853/// Return the normalized advance width (fraction of em) for `c` in Helvetica.
854///
855/// Uses standard Helvetica metrics for ASCII, and Unicode-block heuristics
856/// for non-ASCII ranges.  CJK, full-width, and Hangul characters are
857/// treated as full-width (1.0).  Everything else falls back to 0.556 (the
858/// Helvetica average for Latin lowercase).
859fn helvetica_advance(c: char) -> f32 {
860    let cp = c as u32;
861    match c {
862        // ASCII control / non-printing — zero width
863        '\x00'..='\x1f' | '\x7f' => 0.0,
864        // Space
865        ' ' => 0.278,
866        // Digits
867        '0'..='9' => 0.556,
868        // Common punctuation
869        ',' | '.' | ':' | ';' | '!' | '?' => 0.278,
870        '\'' | '"' => 0.222,
871        '(' | ')' | '[' | ']' | '{' | '}' => 0.333,
872        '-' | '\u{2013}' | '\u{2014}' => 0.333,
873        // Uppercase ASCII — broad average for Helvetica
874        'A'..='Z' => 0.667,
875        // Lowercase ASCII
876        'a'..='z' => 0.556,
877        _ => {
878            // CJK Unified Ideographs and common CJK blocks → full-width
879            if matches!(cp,
880                0x1100..=0x11FF  // Hangul Jamo
881                | 0x2E80..=0x2EFF  // CJK Radicals Supplement
882                | 0x2F00..=0x2FDF  // Kangxi Radicals
883                | 0x3000..=0x303F  // CJK Symbols and Punctuation
884                | 0x3040..=0x309F  // Hiragana
885                | 0x30A0..=0x30FF  // Katakana
886                | 0x3100..=0x312F  // Bopomofo
887                | 0x3130..=0x318F  // Hangul Compatibility Jamo
888                | 0x3190..=0x31FF  // various CJK
889                | 0x3200..=0x32FF  // Enclosed CJK
890                | 0x3300..=0x33FF  // CJK Compatibility
891                | 0x3400..=0x4DBF  // CJK Extension A
892                | 0x4E00..=0x9FFF  // CJK Unified Ideographs
893                | 0xA000..=0xA48F  // Yi Syllables
894                | 0xA490..=0xA4CF  // Yi Radicals
895                | 0xAC00..=0xD7AF  // Hangul Syllables
896                | 0xF900..=0xFAFF  // CJK Compatibility Ideographs
897                | 0xFE10..=0xFE1F  // Vertical Forms
898                | 0xFE30..=0xFE4F  // CJK Compatibility Forms
899                | 0xFF00..=0xFFEF  // Halfwidth and Fullwidth Forms
900                | 0x1B000..=0x1B0FF // Kana Supplement
901                | 0x20000..=0x2A6DF // CJK Extension B
902                | 0x2A700..=0x2CEAF // CJK Extensions C/D/E
903                | 0x2CEB0..=0x2EBEF // CJK Extension F
904                | 0x30000..=0x3134F // CJK Extension G
905            ) {
906                1.0
907            } else {
908                // Latin Extended, Cyrillic, Greek, Arabic, Hebrew, etc.
909                0.556
910            }
911        }
912    }
913}
914
915/// Escape a string for PDF literal string syntax.
916fn 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            // Non-ASCII: encode as UTF-16BE with BOM for PDF
925            _ => {
926                // For simplicity, skip non-ASCII chars in text positioning
927                // (they'll still be in the document via the image)
928                out.push('?');
929            }
930        }
931    }
932    out
933}
934
935/// Convert a DjVu shape to a PDF rectangle [x1, y1, x2, y2] in points.
936///
937/// DjVu annotation coordinates use bottom-left origin (same as PDF), so no
938/// vertical flip is needed — only the point conversion of each edge. The
939/// bounding box, and the empty/degenerate → `None` rule, are the shared
940/// [`crate::export_common::shape_bbox`]; a zero-area shape encloses no link
941/// region and is dropped. Because `px_to_pt` is monotonic, taking the bounding
942/// box in pixel space and converting its edges yields the same points as the
943/// previous per-point fold in point space.
944fn 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
953// ---- Bookmarks (PDF outline) ------------------------------------------------
954
955/// Build PDF outline objects from NAVM bookmarks.
956/// Returns the outline root object ID, or None if no bookmarks.
957fn 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    // Flatten the bookmark tree into outline item objects
969    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
988/// Recursively build outline items. Returns IDs of top-level items at this level.
989fn 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        // Resolve bookmark URL to page index
1016        let dest = resolve_bookmark_dest(&bm.url, page_ids);
1017
1018        // Build children
1019        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
1042/// Count total outline items (including nested children).
1043fn 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
1051/// Resolve a DjVu bookmark URL to a PDF destination string.
1052/// DjVu internal URLs look like `#page_N` or `#+N` or `#-N`.
1053fn 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    // External URL or unparseable — use URI action
1061    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// ---- Public API -------------------------------------------------------------
1070
1071/// Convert a DjVu document to PDF bytes.
1072///
1073/// Options for DjVu → PDF conversion.
1074///
1075/// Use `PdfOptions::default()` for sensible defaults:
1076/// - 150 DPI output (screen-quality, ~16× fewer pixels than native 600 DPI)
1077/// - DCTDecode (JPEG quality 80) for color backgrounds
1078/// - 1-bit FlateDecode for bilevel masks
1079/// - Bilevel-only pages skip RGB render entirely (direct 1-bit embed)
1080#[derive(Debug, Clone)]
1081pub struct PdfOptions {
1082    /// JPEG quality for background image encoding (1–100).
1083    ///
1084    /// Higher values produce better quality at larger file sizes.
1085    /// Set to `None` to use lossless FlateDecode (PNG-like, larger output).
1086    pub jpeg_quality: Option<u8>,
1087
1088    /// Output resolution in DPI.
1089    ///
1090    /// Controls the pixel dimensions of embedded images. Lower values produce
1091    /// smaller files and faster exports; higher values preserve more detail.
1092    ///
1093    /// - `150` — screen quality (default); ~16× fewer pixels than native 600 DPI
1094    /// - `300` — print quality
1095    /// - `0` — use native page DPI (maximum quality, slowest)
1096    pub output_dpi: u32,
1097
1098    /// Opt-in per-page adaptive raster encoding (default `false`).
1099    ///
1100    /// When `jpeg_quality` is `Some`, the default behaviour always emits
1101    /// DCTDecode (JPEG). On near-flat/text-dominated colour pages this can be
1102    /// *larger* than plain FlateDecode at no quality gain — JPEG's DCT
1103    /// overhead doesn't pay for itself when there's little photographic
1104    /// detail to amortize it against (see `PDF_DCT_PROBE` in
1105    /// `PERF_EXPERIMENTS.md`).
1106    ///
1107    /// When `true`, each page's rendered RGB is encoded *both* ways and
1108    /// whichever stream is smaller is embedded — losslessly (FlateDecode) when
1109    /// Deflate wins, lossy (DCTDecode) when JPEG wins. Only one page's pair of
1110    /// encodings is ever held in memory at a time (the loser is dropped
1111    /// immediately), so this doesn't change the O(1)-per-page memory profile.
1112    /// Has no effect when `jpeg_quality` is `None` (already all-Deflate).
1113    pub adaptive_raster: bool,
1114
1115    /// Opt-in CCITT Group 4 (T.6) encoding for JB2 bilevel masks (default `false`).
1116    ///
1117    /// Every page's mask (the bilevel-only `/Im0` fast path *and* the `/Mask0`
1118    /// overlay on mixed pages) is currently always emitted as Deflate of the
1119    /// raw 1-bit raster. For scanned/text-dominated bilevel content, Group 4
1120    /// (fax) run-length coding exploits row-to-row redundancy that Deflate's
1121    /// generic LZ77 window doesn't reliably catch, often 1.5-2x+ smaller.
1122    ///
1123    /// When `true`, each mask is encoded *both* ways (Deflate and G4 via
1124    /// [`crate::smmr::encode_g4`]) and whichever stream is smaller is
1125    /// embedded — this can never regress a page's mask size, the same
1126    /// "encode both, keep smaller" pattern as `adaptive_raster`. On
1127    /// halftone/dithered bilevel content (rare for JB2 masks, which are
1128    /// normally already-segmented text/line-art) G4's run-length model can
1129    /// lose to Deflate; the per-mask min guards against that.
1130    pub ccitt_g4: bool,
1131
1132    /// Opt-in true-MRC layering (default `false`).
1133    ///
1134    /// The default mixed-page path embeds `/Im0` as the **composited** render
1135    /// (background WITH text) at `output_dpi`, then repaints the text via the
1136    /// stencils anyway — the raster layer wastes bits on high-frequency glyph
1137    /// edges (JPEG ringing halos) and is stored upsampled relative to the
1138    /// background's native BG44 resolution. With `mrc: true`, pages whose
1139    /// foreground is fully covered by stencils embed the **background layer
1140    /// only** (no composited text) at its native subsampled resolution; the
1141    /// stencils carry the text (coloured per the FGbz/uniform-FG44 policy).
1142    /// Pages where the stencil is skipped (multi-colour FG44), photo-only
1143    /// pages, and bilevel pages fall back to the default path unchanged.
1144    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    /// High-quality archival preset: native DPI, JPEG quality 90.
1161    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
1172/// Convert a DjVu document to PDF bytes using custom options.
1173///
1174/// See [`PdfOptions`] for available settings.
1175pub 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
1184/// Convert a DjVu document to PDF, streaming the output to `sink` (#606).
1185///
1186/// Object bodies are written as they are produced and dropped immediately, so
1187/// peak memory stays O(1 page) plus the xref bookkeeping instead of holding
1188/// every object body *and* a second full serialization buffer. Output bytes
1189/// are identical to [`djvu_to_pdf_with_options`] (which now wraps this with a
1190/// `Vec` sink). Wrap `sink` in a [`std::io::BufWriter`] for file output.
1191///
1192/// # Errors
1193///
1194/// Returns `PdfError` if page rendering, text layer parsing, or writing to
1195/// `sink` fails. On error the sink may contain a partial PDF; the library does
1196/// not clean it up or provide atomic replacement (that policy belongs to the
1197/// CLI/application layer).
1198pub 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
1207/// Convert a DjVu document to PDF while reporting progress through `observer`.
1208///
1209/// With the `parallel` feature, cancellation is polled before each bounded
1210/// render batch. Work already scheduled in the current batch may complete
1211/// before the cancellation is observed.
1212///
1213/// On error, `sink` may contain a partial PDF; the library does not clean it
1214/// up or provide atomic replacement (that policy belongs to the CLI/application
1215/// layer).
1216pub 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
1225/// This produces a PDF 1.4 file with:
1226/// - Rasterized page images (IW44 background + JB2 mask composite)
1227/// - Invisible text layer for search and selection
1228/// - Bookmarks (PDF outline) from NAVM
1229/// - Hyperlink annotations from ANTz
1230///
1231/// Background images are encoded as DCTDecode (JPEG at quality 80) by default,
1232/// producing significantly smaller files than the legacy FlateDecode path.
1233/// Use [`djvu_to_pdf_with_options`] with `jpeg_quality: None` for lossless output.
1234///
1235/// # Errors
1236///
1237/// Returns `PdfError` if page rendering or text layer parsing fails.
1238pub 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    // Reserve IDs for catalog and pages
1251    let catalog_id = w.alloc_id(); // 1
1252    let pages_id = w.alloc_id(); // 2
1253
1254    // Reserve a font object ID
1255    let font_id = w.alloc_id(); // 3
1256    w.add_obj(font_id, font_dict())?;
1257
1258    let page_count = doc.page_count();
1259
1260    // Emit one page's objects (rendered body or a blank-page fallback) and return
1261    // its page-object id. Shared by both the parallel and sequential paths.
1262    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                // Fallback: blank page at native dimensions
1270                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    // With the `parallel` feature, render all pages concurrently via rayon, then
1289    // emit sequentially (PdfWriter is not Send).
1290    // #606: render in bounded chunks so the parallel path holds O(chunk) page
1291    // bodies instead of all `page_count` at once, then emit each chunk in
1292    // order (identical output ordering → identical bytes).
1293    #[cfg(feature = "parallel")]
1294    {
1295        use rayon::prelude::*;
1296        // Chunk size trades bounded memory (O(chunk) retained bodies) against
1297        // scheduling: too small starves the pool at each chunk barrier on
1298        // uneven pages. 8x threads measured within noise of the old
1299        // collect-everything path on a 504-page doc while bounding bodies.
1300        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                    // #629: render on a cold clone so the decode caches die
1311                    // with it — the export never revisits a page, and caching
1312                    // on the document made peak RSS grow O(pages).
1313                    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    // #449: sequential path renders, emits, and drops one page at a time, holding
1330    // O(1) page bodies in memory instead of collecting all `page_count` rendered
1331    // bodies first (peak RSS O(pages × body) → O(1 page); mirrors TIFF_STREAM).
1332    #[cfg(not(feature = "parallel"))]
1333    for i in 0..page_count {
1334        if observer.cancelled() {
1335            return Err(PdfError::Cancelled);
1336        }
1337        // #629: render on a cold clone — see the parallel path above.
1338        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    // Build outline from bookmarks
1347    let outline_id = build_outline(&mut w, doc.bookmarks(), &page_obj_ids)?;
1348
1349    // Pages object
1350    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    // Catalog
1362    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        // At 72 dpi, 72 pixels = 72 points
1388        assert!((px_to_pt(72.0, 72.0) - 72.0).abs() < 0.01);
1389        // At 300 dpi, 300 pixels = 72 points
1390        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        // Compressed data should be non-empty
1424        assert!(!compressed.is_empty());
1425        // Decompress and verify
1426        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")); // 0, 1, 2
1471    }
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        // Should fall through to bare number parse or be empty
1493        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        // Non-ASCII chars should be replaced with ?
1519        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); // x1
1534        assert!((rect.2 - 72.0).abs() < 0.01); // x2 = 300 * 72/300
1535    }
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    // ── DCTDecode / PdfOptions tests ──────────────────────────────────────────
1591
1592    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    /// `PdfOptions::default()` uses jpeg_quality = Some(80).
1742    #[test]
1743    fn pdf_options_default_is_jpeg80() {
1744        let opts = PdfOptions::default();
1745        assert_eq!(opts.jpeg_quality, Some(80));
1746    }
1747
1748    /// JPEG encoding roundtrip: `encode_rgb_to_jpeg` returns a non-empty JPEG.
1749    #[test]
1750    fn encode_rgb_to_jpeg_returns_jpeg() {
1751        // 4×4 solid red image
1752        let rgb = [255u8, 0, 0].repeat(16); // 16 pixels * 3 channels
1753        let jpeg = encode_rgb_to_jpeg(&rgb, 4, 4, 80);
1754        assert!(!jpeg.is_empty(), "JPEG output must not be empty");
1755        // JPEG starts with FF D8
1756        assert_eq!(jpeg[0], 0xFF);
1757        assert_eq!(jpeg[1], 0xD8);
1758    }
1759
1760    /// `make_dct_stream` embeds /Filter /DCTDecode in the PDF stream dict.
1761    #[test]
1762    fn make_dct_stream_has_dctdecode_filter() {
1763        let fake_jpeg = b"\xFF\xD8\xFF\xD9"; // minimal JPEG markers
1764        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    /// DCT PDF is smaller than deflate PDF for the same page.
1774    #[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    /// Output PDF contains /DCTDecode when jpeg_quality is set.
1808    #[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    /// Output PDF does NOT contain /DCTDecode when jpeg_quality is None.
1827    #[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    /// `djvu_to_pdf` (default, DCT at 80) is smaller than FlateDecode.
1846    #[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    // ── PDF_ADAPTIVE_RASTER: opt-in per-page Deflate-vs-JPEG choice ─────────────
1868
1869    #[test]
1870    fn adaptive_raster_defaults_to_off() {
1871        assert!(!PdfOptions::default().adaptive_raster);
1872        assert!(!PdfOptions::archival().adaptive_raster);
1873    }
1874
1875    /// With `adaptive_raster: false` (the default), output must be byte-identical
1876    /// to the pre-existing always-DCT behaviour.
1877    #[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    /// On a near-flat colour scan (`PDF_DCT_PROBE`'s regression case), JPEG-80 is
1893    /// 3.1x larger than Deflate at no SSIM gain. `adaptive_raster: true` must pick
1894    /// Deflate on every such page and produce a visibly smaller whole-file PDF.
1895    #[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        // Expect a substantial win (measured ~1.6x on this corpus file), not a
1921        // rounding-error difference.
1922        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    /// `adaptive_raster: true` must never be *larger* than always-DCT: it's a
1929    /// per-page min, so image-heavy pages where JPEG already wins are unchanged.
1930    #[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    // ── PDF_G4: opt-in CCITTFaxDecode (G4/T.6) for JB2 masks ────────────────────
1946
1947    #[test]
1948    fn ccitt_g4_defaults_to_off() {
1949        assert!(!PdfOptions::default().ccitt_g4);
1950        assert!(!PdfOptions::archival().ccitt_g4);
1951    }
1952
1953    /// With `ccitt_g4: false` (the default), output must be byte-identical to
1954    /// the pre-existing always-Deflate mask behaviour.
1955    #[test]
1956    fn ccitt_g4_off_is_byte_identical_to_default() {
1957        let doc = load_doc("boy_jb2.djvu"); // Sjbz-only (bilevel fast path)
1958        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    /// `ccitt_g4: true` must produce a PDF containing `/CCITTFaxDecode` for a
1971    /// bilevel document, and must never be larger than the Deflate-only default
1972    /// (it's a per-mask min, same pattern as `adaptive_raster`).
1973    #[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    /// On a real scanned bilevel corpus doc (`watchmaker.djvu`), `ccitt_g4: true`
1996    /// must shrink the whole-file PDF meaningfully (measured ~1.7x on this file's
1997    /// masks — see `PDF_G4` in `PERF_EXPERIMENTS.md`).
1998    #[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    /// `collect_mask_stream` with `ccitt_g4: true` still returns `None` for a
2026    /// page without `Sjbz` (same short-circuit as the Deflate-only path).
2027    #[test]
2028    fn collect_mask_stream_g4_returns_none_for_no_sjbz() {
2029        let doc = load_doc("chicken.djvu"); // no Sjbz
2030        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    // ── Text layer ────────────────────────────────────────────────────────────
2073
2074    /// A document with TXTz must embed invisible text (BT…ET blocks).
2075    #[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        // PDF content streams are deflated, but "BT" / "ET" may appear in stream
2080        // dict or in the raw uncompressed bytes we can observe at the dict level.
2081        // More reliably: we check that at least one page's content stream was
2082        // added (the file is larger than a document without text).
2083        assert!(!pdf.is_empty(), "PDF must not be empty");
2084        // The text content stream dict contains /Font when text is present.
2085        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    /// `build_text_content` returns empty when the page has no text layer.
2093    #[test]
2094    fn build_text_content_no_text_layer_returns_empty() {
2095        let doc = load_doc("chicken.djvu"); // no TXTz
2096        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    /// `build_text_content` returns non-empty when the page has a text layer.
2105    #[test]
2106    fn build_text_content_with_text_layer_returns_non_empty() {
2107        let doc = load_doc("colorbook.djvu"); // has TXTz
2108        // Find the first page that actually has a text layer
2109        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        // If no page had non-empty text, that's fine — fixture may have empty zones
2123    }
2124
2125    // ── Bookmarks (PDF outline) ───────────────────────────────────────────────
2126
2127    /// A document with NAVM bookmarks must produce /Outlines in the PDF catalog.
2128    #[test]
2129    fn pdf_with_bookmarks_contains_outlines() {
2130        let doc = load_doc("links.djvu"); // has NAVM
2131        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    /// A document without bookmarks must NOT produce /Outlines.
2140    #[test]
2141    fn pdf_without_bookmarks_has_no_outlines() {
2142        let doc = load_doc("chicken.djvu"); // no NAVM
2143        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    /// `resolve_bookmark_dest` resolves `#page_N` to a /Dest reference.
2152    /// DjVu page anchors are 1-based: `#page_1` = index 0, `#page_2` = index 1.
2153    #[test]
2154    fn resolve_bookmark_dest_page_anchor() {
2155        let page_ids = [10usize, 20, 30];
2156        // #page_1 is 1-based → index 0 → page_ids[0] = 10
2157        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        // #page_2 → index 1 → page_ids[1] = 20
2161        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    /// `resolve_bookmark_dest` falls back to /A /URI for external URLs.
2169    #[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    /// `resolve_bookmark_dest` returns empty string for empty URL.
2179    #[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    // ── Hyperlink annotations ─────────────────────────────────────────────────
2186
2187    /// `collect_link_annot_bodies` runs without error on a document with ANTz.
2188    #[test]
2189    fn collect_link_annot_bodies_runs_without_error() {
2190        let doc = load_doc("czech.djvu"); // has ANTz
2191        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        // Test passes if no panic
2198    }
2199
2200    /// `collect_link_annot_bodies` builds correct annotation body for a link.
2201    ///
2202    /// Exercises the annotation formatting path directly without needing a
2203    /// specific fixture with Rect-shaped hyperlinks.
2204    #[test]
2205    fn link_annot_body_format() {
2206        use crate::annotation::{MapArea, Rect as ARect, Shape};
2207
2208        // Build a synthetic Rect-shaped hyperlink
2209        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    // ── Bilevel-only pages ────────────────────────────────────────────────────
2240
2241    /// Bilevel-only page (Sjbz, no BG44) must use /ImageMask in the PDF.
2242    #[test]
2243    fn bilevel_only_page_has_image_mask() {
2244        let doc = load_doc("boy_jb2.djvu"); // Sjbz-only
2245        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    // ── Mixed page (Sjbz + BG44) — mask overlay ──────────────────────────────
2251
2252    /// A page with both Sjbz (foreground mask) and BG44 (background) must
2253    /// embed both an /Im0 image and a /Mask0 ImageMask XObject.
2254    /// (irish.djvu: Sjbz+BG44+FGbz — the palette path emits /Mask0, /Mask1, …)
2255    #[test]
2256    fn mixed_page_has_both_image_and_mask_xobject() {
2257        let doc = load_doc("irish.djvu"); // Sjbz+BG44+FGbz
2258        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    /// A page whose foreground colour is continuous-tone (FG44, no FGbz
2269    /// palette) must NOT get a stencil: a black stencil would flatten the
2270    /// coloured text, and the composited /Im0 already carries it (#620).
2271    #[test]
2272    fn fg44_page_skips_mask_stencil() {
2273        let doc = load_doc("colorbook.djvu"); // Sjbz+BG44+FG44, no FGbz
2274        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    // ── render_dims / output_dpi ──────────────────────────────────────────────
2285
2286    /// When output_dpi is lower than native DPI the PDF is smaller.
2287    #[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    // ── PdfOptions::archival() ────────────────────────────────────────────────
2321
2322    #[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    // ── pdf_escape_string ─────────────────────────────────────────────────────
2341
2342    #[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    // ── helvetica_advance ─────────────────────────────────────────────────────
2360
2361    #[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        // CJK Unified Ideograph — should return 1.0
2384        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        // Cyrillic falls through to the 0.556 default
2395        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    // ── collect_mask_stream ───────────────────────────────────────────────────
2412
2413    #[test]
2414    fn collect_mask_stream_returns_none_for_no_sjbz() {
2415        let doc = load_doc("chicken.djvu"); // no Sjbz
2416        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"); // has Sjbz
2427        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        // Must contain /ImageMask keyword
2435        assert!(
2436            body.windows(9).any(|w| w == b"ImageMask"),
2437            "mask stream must contain /ImageMask"
2438        );
2439    }
2440
2441    // ── shape_to_pdf_rect ─────────────────────────────────────────────────────
2442
2443    #[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); // 100px * 72/100dpi = 72pt
2458        assert!((y2 - 36.0).abs() < 0.01); // 50px * 72/100dpi = 36pt
2459    }
2460
2461    // ── px_to_pt ─────────────────────────────────────────────────────────────
2462
2463    #[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        // 300px at 300dpi = 72pt
2471        assert!((px_to_pt(300.0, 300.0) - 72.0).abs() < 0.001);
2472    }
2473
2474    // ── emit_word_span guards ─────────────────────────────────────────────────
2475
2476    #[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        // height=1px at 300dpi → h = 1*72/300 = 0.24pt < 0.5 → skip
2494        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    // ── build_outline with nested bookmarks ──────────────────────────────────
2506
2507    #[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        // Serialize and check that /First and /Last are present
2535        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    // Lines 411-415: hyperlink annotation block (/Annots [...]).
2552    // Build a synthetic single-page DjVu with an ANTz maparea URL, then convert.
2553    #[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        // Minimal INFO: width=100, height=100, dpi=0 (default), rest zero.
2572        let mut info = vec![0u8; 10];
2573        info[1] = 100; // width
2574        info[3] = 100; // height
2575        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    /// Page with corrupted ANTz (invalid BZZ): `hyperlinks()` errors, so
2601    /// `collect_link_annot_bodies` returns empty (line 332 `Err(_) => Vec::new()`).
2602    #[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; // width
2609        info[3] = 100; // height
2610        // Garbage bytes that are not valid BZZ — decoding will fail
2611        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    /// Page whose render fails (0×0 dimensions, no image data) triggers the blank
2638    /// page fallback at lines 840-850: `rendered_pages[i]` is None so a blank
2639    /// /Page object is emitted with the native MediaBox dimensions.
2640    #[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        // INFO chunk: width=0, height=0, dpi=0 (all zeros). No Sjbz or BG44 so
2646        // is_bilevel_only=false, and render_dims returns (0,0), which makes
2647        // render_pixmap return InvalidDimensions → render_page_data returns Err
2648        // → .ok() yields None → blank page fallback fires.
2649        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}