Skip to main content

caj2pdf_core/operations/
inspect.rs

1// SPDX-License-Identifier: MIT
2
3//! [`inspect`], the HN/C8 per-page report, and the inputs of an outline
4//! import.
5
6use super::observe::{Observer, Progress, refused};
7use super::{Bookmark, BookmarkVisitor, Detection, InputFormat, pdf_range};
8use crate::caj::parse_metadata;
9use crate::hnc8::{
10    ApplicationInfoReport, ApplicationInfoTail, Header, Hnc8Reader, ImageRecord, OutlineReport,
11    PageRecord, TextStructure, Variant,
12};
13use crate::kdh::{HEADER_SIGNATURE, KdhPdfSource};
14use crate::pdf::PdfIndex;
15use crate::{CountingSource, Error, ErrorKind, Limits, RangedSource, Result, read_exact_at};
16
17/// What [`inspect`] reads besides the summary.
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub struct InspectOptions {
20    /// The input family; `None` detects it from the leading signature.
21    pub format: Option<InputFormat>,
22    /// List the outline entries where the format has a readable outline (CAJ
23    /// and HN-A); otherwise they are only counted.
24    pub bookmarks: bool,
25    /// Read the document-level [`Structure`] for a per-page report.
26    pub structure: bool,
27}
28
29/// Structure-only document facts; never document text.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum Structure {
32    /// The leading wrapper bytes (at most 32) and whether they are the
33    /// supported signature.
34    Kdh { signature: Vec<u8>, supported: bool },
35    /// The HN/C8 page-index layout and application-info tail.
36    Hnc8 {
37        header: Header,
38        page_row_bytes: u64,
39        application_info: Option<ApplicationInfoTail>,
40    },
41}
42
43/// Bounded summary returned by [`inspect`].
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct DocumentInfo {
46    pub format: InputFormat,
47    /// The measured HN/C8 container layout; `None` for other families.
48    pub variant: Option<Variant>,
49    /// `None` for a family [`super::convert`] refuses, and for a KDH wrapper
50    /// whose signature is unsupported.
51    pub page_count: Option<u32>,
52    /// Whether the document has an outline; `None` when unknown.
53    pub has_outline: Option<bool>,
54    /// Outline entries (CAJ, and the HN-A entries written after validation);
55    /// `None` if the outline layout is unknown or not counted.
56    pub bookmark_count: Option<u32>,
57    /// The listed outline, with [`InspectOptions::bookmarks`], for CAJ and
58    /// HN-A.
59    pub bookmarks: Option<Vec<Bookmark>>,
60    /// HN-A entries skipped or clamped while reading the outline; empty
61    /// otherwise.
62    pub outline: OutlineReport,
63    /// The C8 application-info package; absent for other families. A
64    /// defective package is reported here, not as an error.
65    pub application_info: ApplicationInfoReport,
66    /// Read only with [`InspectOptions::structure`], for KDH and HN/C8.
67    pub structure: Option<Structure>,
68    /// Input bytes read, detection included.
69    pub input_bytes_read: u64,
70}
71
72impl DocumentInfo {
73    fn unknown(format: InputFormat) -> Self {
74        Self {
75            format,
76            variant: None,
77            page_count: None,
78            has_outline: None,
79            bookmark_count: None,
80            bookmarks: None,
81            outline: OutlineReport::default(),
82            application_info: ApplicationInfoReport::default(),
83            structure: None,
84            input_bytes_read: 0,
85        }
86    }
87
88    fn indexed(&mut self, index: &PdfIndex) {
89        // The PDF index enforces `Limits::max_pages`, a u32.
90        self.page_count = Some(index.pages().len() as u32);
91        self.has_outline = Some(index.has_outlines());
92    }
93}
94
95/// Read bounded document metadata without writing or decoding any page.
96///
97/// The family is detected unless [`InspectOptions::format`] names it, and
98/// reported to [`Progress::format`]; an empty or unrecognized input is
99/// refused as by [`super::convert`]. PDF and KDH are indexed, CAJ and HN/C8
100/// metadata and outlines are read, and NH, TEB and CAA report only their family.
101pub fn inspect<S: RangedSource>(
102    source: &mut S,
103    options: &InspectOptions,
104    limits: &Limits,
105    progress: &mut dyn Progress,
106) -> Result<DocumentInfo> {
107    let observer = Observer::new(progress);
108    let mut tracked = observer.track(source);
109    let detection = observer.resolve(&mut tracked, options.format, limits)?;
110    let mut input_bytes_read = 0;
111    let mut info = read_info(
112        &mut CountingSource::new(&mut tracked, &mut input_bytes_read),
113        detection,
114        options,
115        limits,
116        &observer,
117    )?;
118    info.input_bytes_read = input_bytes_read.saturating_add(detection.bytes_read);
119    Ok(info)
120}
121
122fn read_info<S: RangedSource>(
123    source: &mut S,
124    detection: Detection,
125    options: &InspectOptions,
126    limits: &Limits,
127    observer: &Observer<'_>,
128) -> Result<DocumentInfo> {
129    let mut info = DocumentInfo::unknown(detection.format);
130    match detection.format {
131        InputFormat::Pdf => {
132            let range = pdf_range(source.size(), detection.header_offset);
133            info.indexed(&PdfIndex::open(source, range, limits, observer)?);
134        }
135        InputFormat::Kdh => {
136            if options.structure {
137                // A different signature is reported instead of an error, so
138                // that it can be diagnosed.
139                let mut signature =
140                    vec![0; source.size().min(HEADER_SIGNATURE.len() as u64) as usize];
141                read_exact_at(source, 0, &mut signature, limits, observer)?;
142                let supported = signature == HEADER_SIGNATURE;
143                info.structure = Some(Structure::Kdh {
144                    signature,
145                    supported,
146                });
147                if !supported {
148                    return Ok(info);
149                }
150            }
151            let mut decoded = KdhPdfSource::open(source, limits, observer)?;
152            let range = pdf_range(decoded.size(), 0);
153            info.indexed(&PdfIndex::open(&mut decoded, range, limits, observer)?);
154        }
155        InputFormat::Caj => {
156            let metadata = parse_metadata(source, limits, observer)?;
157            info.page_count = Some(metadata.page_count);
158            info.has_outline = Some(!metadata.bookmarks.is_empty());
159            // `parse_metadata` enforces `Limits::max_bookmarks`, a u32.
160            info.bookmark_count = Some(metadata.bookmarks.len() as u32);
161            info.bookmarks = options.bookmarks.then_some(metadata.bookmarks);
162        }
163        InputFormat::Hn | InputFormat::C8 => {
164            inspect_hnc8(source, &mut info, options, limits, observer)?;
165        }
166        InputFormat::Nh | InputFormat::Teb | InputFormat::Caa => {}
167    }
168    Ok(info)
169}
170
171/// Keep only bounded outline and application-info metadata; image payloads
172/// are never read. The structure adds the page-index layout and the
173/// application-info tail.
174fn inspect_hnc8<S: RangedSource>(
175    source: &mut S,
176    info: &mut DocumentInfo,
177    options: &InspectOptions,
178    limits: &Limits,
179    observer: &Observer<'_>,
180) -> Result<()> {
181    let mut reader = Hnc8Reader::open(source, limits, observer)?;
182    let header = reader.header();
183    info.variant = Some(header.variant);
184    info.page_count = Some(header.page_count);
185    if options.structure {
186        info.structure = Some(Structure::Hnc8 {
187            header,
188            page_row_bytes: reader.page_row_bytes(),
189            application_info: reader.application_info_tail()?,
190        });
191    }
192    info.application_info = reader.application_info_report()?;
193    let Some(count) = reader.declared_bookmark_count() else {
194        return Ok(());
195    };
196    let map = |page: u32| Some(page - 1);
197    let outline = if options.bookmarks {
198        let mut collected = Collected::new(count, limits)?;
199        let outline = reader.visit_bookmarks(64, header.page_count, map, &mut collected)?;
200        info.bookmarks = Some(collected.items);
201        outline
202    } else {
203        reader.visit_bookmarks(64, header.page_count, map, &mut Counted)?
204    };
205    info.bookmark_count = Some(outline.written);
206    info.has_outline = Some(outline.written != 0);
207    info.outline = outline;
208    Ok(())
209}
210
211/// HN-A bookmarks, with the records and their retained titles counted
212/// against the allocation limit.
213struct Collected<'l> {
214    items: Vec<Bookmark>,
215    limits: &'l Limits,
216    bytes: u64,
217}
218
219impl<'l> Collected<'l> {
220    /// Reserve the `count` declared records. A count over the bookmark limit
221    /// reserves nothing: the outline traversal refuses it first.
222    fn new(count: u32, limits: &'l Limits) -> Result<Self> {
223        let bytes = u64::from(count) * size_of::<Bookmark>() as u64;
224        let mut items = Vec::new();
225        if count <= limits.max_bookmarks {
226            limits.check_allocation(bytes)?;
227            items
228                .try_reserve_exact(count as usize)
229                .map_err(|_| limits.allocation_refused("HN-A outline metadata", bytes))?;
230        }
231        Ok(Self {
232            items,
233            limits,
234            bytes,
235        })
236    }
237}
238
239impl BookmarkVisitor for Collected<'_> {
240    fn visit(&mut self, bookmark: Bookmark) -> Result<()> {
241        self.bytes += bookmark.title.capacity() as u64;
242        self.limits.check_allocation(self.bytes)?;
243        self.items.push(bookmark);
244        Ok(())
245    }
246}
247
248/// Validates and counts HN-A bookmarks without keeping them.
249struct Counted;
250
251impl BookmarkVisitor for Counted {
252    fn visit(&mut self, _: Bookmark) -> Result<()> {
253        Ok(())
254    }
255}
256
257/// A recipient for [`inspect_pages`]. An error it returns ends the report
258/// and is returned as it is.
259pub trait PageVisitor {
260    /// Called once, before the first page.
261    fn begin(&mut self) -> Result<()>;
262    /// A page's row, or `None` when the row could not be read.
263    fn page(&mut self, number: u32, row: Option<&PageRecord>) -> Result<()>;
264    /// One image descriptor of the current page, in order.
265    fn image(&mut self, image: &ImageRecord) -> Result<()>;
266    /// The end of the current page: its text structure or the error reading
267    /// it, or, for a page whose row or image descriptors failed, that
268    /// `error` alone.
269    fn end_page(
270        &mut self,
271        text: Option<&TextStructure>,
272        text_error: Option<&Error>,
273        error: Option<&Error>,
274    ) -> Result<()>;
275    /// Called once, after the last page.
276    fn finish(&mut self) -> Result<()>;
277}
278
279/// Stream one structural record per page of the HN/C8 document that `info`
280/// (from [`inspect`]) describes; image payloads and text content are never
281/// reported. Returns `false` without calling `visitor` when the family has
282/// no per-page records.
283///
284/// Each page uses a fresh cursor, so a malformed page is reported and later
285/// pages are still inspected; only one page's row, the current descriptor
286/// and bounded text-reader state are held at a time. A failure that would
287/// repeat on every later page (reopening the container, a failed read or
288/// cancellation) ends the report with an error.
289pub fn inspect_pages<S: RangedSource>(
290    source: &mut S,
291    info: &DocumentInfo,
292    limits: &Limits,
293    progress: &mut dyn Progress,
294    visitor: &mut dyn PageVisitor,
295) -> Result<bool> {
296    let (Some(_), Some(page_count)) = (info.variant, info.page_count) else {
297        return Ok(false);
298    };
299    let observer = Observer::new(progress);
300    let mut source = observer.track(source);
301    visitor.begin()?;
302    for number in 1..=page_count {
303        let mut reader = Hnc8Reader::probe_at_page(&mut source, limits, &observer, number)?;
304        let row = match reader.next_page() {
305            Ok(row) => row.expect("a probe opens at a declared page"),
306            Err(error) => {
307                let error = page_error(error)?;
308                visitor.page(number, None)?;
309                visitor.end_page(None, None, Some(&error))?;
310                continue;
311            }
312        };
313        visitor.page(number, Some(&row))?;
314        let error = loop {
315            match reader.next_image() {
316                Ok(Some(image)) => visitor.image(&image)?,
317                Ok(None) => break None,
318                Err(error) => break Some(page_error(error)?),
319            }
320        };
321        if error.is_some() {
322            visitor.end_page(None, None, error.as_ref())?;
323            continue;
324        }
325        match reader.inspect_text() {
326            Ok(text) => visitor.end_page(Some(&text), None, None)?,
327            Err(error) => visitor.end_page(None, Some(&page_error(error)?), None)?,
328        }
329    }
330    visitor.finish()?;
331    Ok(true)
332}
333
334/// A failure of one page, reported for that page; a failed read or
335/// cancellation would repeat on every later page and ends the report.
336fn page_error(error: Error) -> Result<Error> {
337    match error.kind {
338        ErrorKind::Cancelled | ErrorKind::Io(_) => Err(error),
339        _ => Ok(error),
340    }
341}
342
343/// The outline of a CAJ document, for an outline import. Any other family
344/// is refused as by [`super::convert`], after [`Progress::format`] names it.
345pub fn read_outline<S: RangedSource>(
346    source: &mut S,
347    limits: &Limits,
348    progress: &mut dyn Progress,
349) -> Result<Vec<Bookmark>> {
350    let observer = Observer::new(progress);
351    let mut source = observer.track(source);
352    match observer.resolve(&mut source, None, limits)?.format {
353        InputFormat::Caj => Ok(parse_metadata(&mut source, limits, &observer)?.bookmarks),
354        _ => Err(refused()),
355    }
356}
357
358/// Index a PDF, whose `%PDF-` header may follow leading bytes, for
359/// [`crate::pdf::PdfOutlineAppender`]. Any other family is refused as by
360/// [`super::convert`], after [`Progress::format`] names it.
361pub fn index_pdf<S: RangedSource>(
362    source: &mut S,
363    limits: &Limits,
364    progress: &mut dyn Progress,
365) -> Result<PdfIndex> {
366    let observer = Observer::new(progress);
367    let mut source = observer.track(source);
368    let detection = observer.resolve(&mut source, None, limits)?;
369    match detection.format {
370        InputFormat::Pdf => {
371            let range = pdf_range(source.size(), detection.header_offset);
372            PdfIndex::open(&mut source, range, limits, &observer)
373        }
374        _ => Err(refused()),
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::{Context, NeverCancel};
382
383    fn hn_a(count: usize, levels: impl Fn(usize) -> u8) -> Vec<u8> {
384        let mut bytes = vec![0; 0x15c + count * 308 + 20];
385        bytes[..8].copy_from_slice(&[72, 78, 0, 0, 0x90, 1, 0, 0]);
386        bytes[0x90] = 1;
387        bytes[0x158] = count as u8;
388        for number in 0..count {
389            let at = 0x15c + number * 308;
390            bytes[at..at + 4].copy_from_slice(b"Root");
391            bytes[at + 280] = levels(number);
392            bytes[at + 304] = 1;
393        }
394        bytes
395    }
396
397    fn listing() -> InspectOptions {
398        InspectOptions {
399            bookmarks: true,
400            ..InspectOptions::default()
401        }
402    }
403
404    #[test]
405    fn outline_collection_accounts_for_records_and_retained_titles() {
406        let bytes = hn_a(1, |_| b'1');
407        for (max_bookmarks, max_allocation_bytes) in
408            [(0, 4096), (1, 1), (1, size_of::<Bookmark>() as u64)]
409        {
410            let limits = Limits {
411                max_bookmarks,
412                max_allocation_bytes,
413                io_chunk_bytes: 1,
414                ..Limits::default()
415            };
416            let error = inspect(&mut &bytes[..], &listing(), &limits, &mut NeverCancel)
417                .unwrap_err()
418                .to_string();
419            assert!(error.contains("limit"), "{error}");
420        }
421    }
422
423    #[test]
424    fn hn_a_outlines_are_listed_or_counted_with_the_same_validation() {
425        // One valid root, then entries whose page 0 is outside the document.
426        let bytes = hn_a(3, |number| if number == 0 { b'1' } else { b'0' });
427        let listed = inspect(
428            &mut &bytes[..],
429            &listing(),
430            &Limits::default(),
431            &mut NeverCancel,
432        )
433        .unwrap();
434        let counted = inspect(
435            &mut &bytes[..],
436            &InspectOptions::default(),
437            &Limits::default(),
438            &mut NeverCancel,
439        )
440        .unwrap();
441        assert_eq!(listed.bookmarks.as_ref().map(Vec::len), Some(1));
442        assert_eq!(counted.bookmarks, None);
443        for info in [&listed, &counted] {
444            assert_eq!(info.variant, Some(Variant::HnA));
445            assert_eq!((info.page_count, info.bookmark_count), (Some(1), Some(1)));
446            assert_eq!(info.has_outline, Some(true));
447            assert_eq!(info.outline.defects, 2);
448            assert!(info.input_bytes_read > 0);
449        }
450    }
451
452    #[test]
453    fn only_repeating_page_failures_end_the_page_report() {
454        let page = Context::Hnc8 {
455            variant: None,
456            page: Some(2),
457            image: None,
458            segment: None,
459            stage: None,
460        };
461        let io = std::io::Error::other("disk failed");
462        for error in [Error::cancelled(), Error::from(ErrorKind::Io(io))] {
463            assert!(page_error(error.at(7).within(page)).is_err());
464        }
465        let error = Error::malformed(7, "page has unread image records").within(page);
466        assert_eq!(
467            page_error(error).unwrap().to_string(),
468            "malformed HN/C8 at byte 7, page 2: page has unread image records"
469        );
470    }
471
472    #[test]
473    fn families_without_page_records_skip_the_page_report() {
474        struct Refuse;
475        impl PageVisitor for Refuse {
476            fn begin(&mut self) -> Result<()> {
477                unreachable!()
478            }
479            fn page(&mut self, _: u32, _: Option<&PageRecord>) -> Result<()> {
480                unreachable!()
481            }
482            fn image(&mut self, _: &ImageRecord) -> Result<()> {
483                unreachable!()
484            }
485            fn end_page(
486                &mut self,
487                _: Option<&TextStructure>,
488                _: Option<&Error>,
489                _: Option<&Error>,
490            ) -> Result<()> {
491                unreachable!()
492            }
493            fn finish(&mut self) -> Result<()> {
494                unreachable!()
495            }
496        }
497        let info = DocumentInfo::unknown(InputFormat::Teb);
498        let reported = inspect_pages(
499            &mut &b"TEB"[..],
500            &info,
501            &Limits::default(),
502            &mut NeverCancel,
503            &mut Refuse,
504        );
505        assert!(!reported.unwrap());
506    }
507}