Skip to main content

caj2pdf_core/
operations.rs

1// SPDX-License-Identifier: MIT
2
3//! Format detection and the facade every adapter calls: [`convert`],
4//! [`inspect`], [`inspect_pages`], [`needs_fonts`], [`read_outline`] and
5//! [`index_pdf`] detect the input and dispatch to the format engines.
6
7mod caa;
8mod convert;
9mod inspect;
10mod observe;
11
12pub use convert::{
13    ConversionOptions, ConversionReport, FONTS_REQUIRE_HNC8, Fonts, ImageCounts, OmittedPage,
14    convert, convert_with_ttkn_response, native_font_mode, needs_fonts,
15};
16pub use inspect::{
17    DocumentInfo, InspectOptions, PageVisitor, Structure, index_pdf, inspect, inspect_pages,
18    read_outline,
19};
20pub use observe::Progress;
21
22use crate::{Cancellation, Limits, RangedSource, Result, read_exact_at};
23use std::ops::Range;
24
25/// Recognized input families. Recognition does not imply conversion support.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum InputFormat {
28    Pdf,
29    Caj,
30    Kdh,
31    Nh,
32    Hn,
33    C8,
34    Teb,
35    /// An observed CAA target descriptor, without document pages.
36    Caa,
37}
38
39impl InputFormat {
40    /// The family's name in upper case, as in `"PDF"` or `"HN"`.
41    pub const fn name(self) -> &'static str {
42        match self {
43            Self::Pdf => "PDF",
44            Self::Caj => "CAJ",
45            Self::Kdh => "KDH",
46            Self::Nh => "NH",
47            Self::Hn => "HN",
48            Self::C8 => "C8",
49            Self::Teb => "TEB",
50            Self::Caa => "CAA",
51        }
52    }
53
54    /// Whether [`convert`] converts this family; [`inspect`] reads every
55    /// family, but reports no page count for the others.
56    pub const fn is_convertible(self) -> bool {
57        matches!(
58            self,
59            Self::Pdf | Self::Caj | Self::Kdh | Self::Hn | Self::C8
60        )
61    }
62}
63
64/// Leading bytes that [`detect_format`] needs to recognize every signature
65/// that must start at byte 0.
66pub const SIGNATURE_BYTES: usize = 5;
67
68/// Leading bytes searched for a `%PDF-` header that does not start at byte 0.
69/// The whole five-byte marker must lie within this prefix.
70pub const PDF_HEADER_SEARCH_BYTES: usize = 1024;
71
72const PDF_SIGNATURE: &[u8] = b"%PDF-";
73
74/// A recognized input family and where its header starts.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct Detection {
77    pub format: InputFormat,
78    /// Offset of the `%PDF-` header; always 0 for the other families. A PDF
79    /// reader should treat the input as starting here (see `docs/pdf-input.md`).
80    pub header_offset: u64,
81    /// Leading input bytes read to reach this result.
82    pub bytes_read: u64,
83}
84
85/// Recognize an input family from its leading bytes, never from a file name:
86/// some observed `.caj` files are plain PDFs.
87///
88/// `prefix` should hold the first `min(PDF_HEADER_SEARCH_BYTES, size)` bytes;
89/// `SIGNATURE_BYTES` suffice for every signature that starts at byte 0, and
90/// bytes beyond `PDF_HEADER_SEARCH_BYTES` are ignored. CAA requires its
91/// complete observed descriptor fields in the same bounded prefix.
92/// Other signatures are recorded in `tests/fixtures/README.md`, `docs/research/caj-format.md`,
93/// `docs/research/kdh-format.md`, and `docs/research/hnc8-container.md`; CAJ, KDH, HN, C8, and
94/// TEB must start at byte 0. If none matches, a `%PDF-` marker elsewhere in
95/// the searched prefix still selects PDF, as observed for files with a leading
96/// newline, byte-order mark, or junk line. Recognition does not imply
97/// support; the selected reader validates the complete header.
98pub fn detect_format(prefix: &[u8]) -> Option<InputFormat> {
99    leading_format(prefix).or_else(|| searched_format(prefix).map(|(format, _)| format))
100}
101
102fn searched_format(prefix: &[u8]) -> Option<(InputFormat, usize)> {
103    let prefix = &prefix[..prefix.len().min(PDF_HEADER_SEARCH_BYTES)];
104    if caa::recognizes(prefix) {
105        Some((InputFormat::Caa, 0))
106    } else {
107        prefix
108            .windows(PDF_SIGNATURE.len())
109            .position(|window| window == PDF_SIGNATURE)
110            .map(|offset| (InputFormat::Pdf, offset))
111    }
112}
113
114fn leading_format(prefix: &[u8]) -> Option<InputFormat> {
115    const SIGNATURES: [(&[u8], InputFormat); 6] = [
116        (PDF_SIGNATURE, InputFormat::Pdf),
117        (b"CAJ", InputFormat::Caj),
118        (b"KDH", InputFormat::Kdh),
119        (b"HN", InputFormat::Hn),
120        (b"\xc8\0\0\0", InputFormat::C8),
121        (b"TEB", InputFormat::Teb),
122    ];
123    SIGNATURES
124        .iter()
125        .find(|(signature, _)| prefix.starts_with(signature))
126        .map(|&(_, format)| format)
127}
128
129/// Read the leading bytes of `source` and recognize its input family as
130/// [`detect_format`] does, also locating a displaced PDF header.
131///
132/// Only `min(SIGNATURE_BYTES, size)` bytes are read when a signature starts
133/// at byte 0; otherwise CAA fields and a displaced PDF header are searched in
134/// at most `PDF_HEADER_SEARCH_BYTES` in total, in reads
135/// no larger than `limits.io_chunk_bytes`. `Ok(None)` means unrecognized.
136pub fn detect_source<S: RangedSource, C: Cancellation>(
137    source: &mut S,
138    limits: &Limits,
139    cancellation: &C,
140) -> Result<Option<Detection>> {
141    limits.validate()?;
142    let mut prefix = [0; PDF_HEADER_SEARCH_BYTES];
143    let size = source.size();
144    let leading = size.min(SIGNATURE_BYTES as u64) as usize;
145    read_prefix(source, &mut prefix, 0..leading, limits, cancellation)?;
146    if let Some(format) = leading_format(&prefix[..leading]) {
147        return Ok(Some(Detection {
148            format,
149            header_offset: 0,
150            bytes_read: leading as u64,
151        }));
152    }
153    let searched = size.min(PDF_HEADER_SEARCH_BYTES as u64) as usize;
154    read_prefix(source, &mut prefix, leading..searched, limits, cancellation)?;
155    Ok(
156        searched_format(&prefix[..searched]).map(|(format, offset)| Detection {
157            format,
158            header_offset: offset as u64,
159            bytes_read: searched as u64,
160        }),
161    )
162}
163
164fn read_prefix<S: RangedSource, C: Cancellation>(
165    source: &mut S,
166    prefix: &mut [u8],
167    range: Range<usize>,
168    limits: &Limits,
169    cancellation: &C,
170) -> Result<()> {
171    let start = range.start;
172    for (index, chunk) in prefix[range].chunks_mut(limits.io_chunk_bytes).enumerate() {
173        let offset = start + index * limits.io_chunk_bytes;
174        read_exact_at(source, offset as u64, chunk, limits, cancellation)?;
175    }
176    Ok(())
177}
178
179/// The PDF viewed from its `%PDF-` header, which may follow leading bytes.
180fn pdf_range(size: u64, header_offset: u64) -> crate::pdf::PdfRange {
181    crate::pdf::PdfRange {
182        offset: header_offset,
183        length: size - header_offset,
184    }
185}
186
187/// One outline entry in document order.
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct Bookmark {
190    /// Zero means a root entry; each child increments the depth by one.
191    pub depth: u32,
192    pub title: String,
193    /// Zero-based page index.
194    pub page_index: u32,
195}
196
197/// A recipient for streamed bookmark entries, called once per entry.
198pub trait BookmarkVisitor {
199    fn visit(&mut self, bookmark: Bookmark) -> Result<()>;
200}
201
202#[cfg(test)]
203mod tests {
204    use super::{
205        Detection, InputFormat, PDF_HEADER_SEARCH_BYTES, SIGNATURE_BYTES, detect_format,
206        detect_source,
207    };
208    use crate::ErrorKind;
209    use crate::test_support::{CancelAfter, NEVER};
210    use crate::{Error, Limits, RangedSource, Result};
211
212    #[test]
213    fn detects_offset_zero_signatures_and_a_displaced_pdf_header() {
214        let cases: [(&[u8], Option<InputFormat>); 16] = [
215            (b"%PDF-1.7", Some(InputFormat::Pdf)),
216            (b"CAJ\0", Some(InputFormat::Caj)),
217            (b"KDH 2.00", Some(InputFormat::Kdh)),
218            (b"HN\0\0", Some(InputFormat::Hn)),
219            (b"\xc8\0\0\0\x01", Some(InputFormat::C8)),
220            (b"TEB", Some(InputFormat::Teb)),
221            (b" %PDF-", Some(InputFormat::Pdf)),
222            (b"\xef\xbb\xbf%PDF-1.4", Some(InputFormat::Pdf)),
223            (b"%PDF", None),
224            (b"\xc8\0\0", None),
225            (b"NH\0\0", None),
226            (b" CAJ", None),
227            (b"\nKDH 2.00", None),
228            (b" TEB", None),
229            (b"caj", None),
230            (b"", None),
231        ];
232        for (prefix, expected) in cases {
233            assert_eq!(detect_format(prefix), expected, "{prefix:?}");
234        }
235    }
236
237    #[test]
238    fn displaced_pdf_header_must_fit_in_the_searched_prefix() {
239        let last = PDF_HEADER_SEARCH_BYTES - b"%PDF-".len();
240        let mut prefix = vec![b'x'; last];
241        prefix.extend_from_slice(b"%PDF-1.7");
242        assert_eq!(detect_format(&prefix), Some(InputFormat::Pdf));
243        prefix.insert(0, b'x');
244        assert_eq!(detect_format(&prefix), None);
245    }
246
247    #[test]
248    fn signature_bytes_cover_the_longest_signature() {
249        assert_eq!(
250            detect_format(&b"%PDF-1.7"[..SIGNATURE_BYTES]),
251            Some(InputFormat::Pdf)
252        );
253        assert_eq!(detect_format(&b"%PDF-"[..SIGNATURE_BYTES - 1]), None);
254    }
255
256    /// Serves `bytes` but claims `size`, recording every read.
257    struct Source {
258        bytes: Vec<u8>,
259        size: u64,
260        reads: Vec<(u64, usize)>,
261    }
262
263    impl Source {
264        fn new(bytes: Vec<u8>) -> Self {
265            Self {
266                size: bytes.len() as u64,
267                bytes,
268                reads: Vec::new(),
269            }
270        }
271    }
272
273    impl RangedSource for Source {
274        fn size(&self) -> u64 {
275            self.size
276        }
277
278        fn read_at(&mut self, offset: u64, destination: &mut [u8]) -> Result<usize> {
279            self.reads.push((offset, destination.len()));
280            let start = (offset as usize).min(self.bytes.len());
281            let available = &self.bytes[start..];
282            let count = available.len().min(destination.len());
283            destination[..count].copy_from_slice(&available[..count]);
284            Ok(count)
285        }
286    }
287
288    fn detect(source: &mut Source, limits: &Limits) -> Result<Option<Detection>> {
289        detect_source(source, limits, &NEVER)
290    }
291
292    fn detection(format: InputFormat, header_offset: u64, bytes_read: u64) -> Option<Detection> {
293        Some(Detection {
294            format,
295            header_offset,
296            bytes_read,
297        })
298    }
299
300    #[test]
301    fn offset_zero_signatures_read_only_the_signature_prefix() {
302        let mut source = Source::new(b"CAJ\0 and %PDF- later".to_vec());
303        let found = detect(&mut source, &Limits::default()).unwrap();
304        assert_eq!(found, detection(InputFormat::Caj, 0, 5));
305        assert_eq!(source.reads, [(0, SIGNATURE_BYTES)]);
306
307        let mut short = Source::new(b"HN".to_vec());
308        let found = detect(&mut short, &Limits::default()).unwrap();
309        assert_eq!(found, detection(InputFormat::Hn, 0, 2));
310    }
311
312    #[test]
313    fn displaced_pdf_headers_are_located_within_the_bounded_prefix() {
314        let mut junk = vec![b'#'; 100];
315        junk.push(b'\n');
316        for (prefix, offset) in [
317            (b"\n".as_slice(), 1),
318            (b"\xef\xbb\xbf".as_slice(), 3),
319            (junk.as_slice(), 101),
320        ] {
321            let mut bytes = prefix.to_vec();
322            bytes.extend_from_slice(&[b"%PDF-1.7\n".as_slice(), &[b' '; 4096]].concat());
323            let mut source = Source::new(bytes);
324            let found = detect(&mut source, &Limits::default()).unwrap();
325            let searched = PDF_HEADER_SEARCH_BYTES as u64;
326            assert_eq!(found, detection(InputFormat::Pdf, offset, searched));
327            let tail = PDF_HEADER_SEARCH_BYTES - SIGNATURE_BYTES;
328            assert_eq!(source.reads, [(0, SIGNATURE_BYTES), (5, tail)]);
329        }
330    }
331
332    #[test]
333    fn the_search_reads_in_io_chunks_and_stops_at_the_source_end() {
334        let limits = Limits {
335            io_chunk_bytes: 4,
336            ..Limits::default()
337        };
338        let mut source = Source::new(b"\r\n%PDF-1.0".to_vec());
339        let found = detect(&mut source, &limits).unwrap();
340        assert_eq!(found, detection(InputFormat::Pdf, 2, 10));
341        assert_eq!(source.reads, [(0, 4), (4, 1), (5, 4), (9, 1)]);
342    }
343
344    #[test]
345    fn a_header_beyond_the_searched_prefix_or_an_empty_source_is_unrecognized() {
346        let mut bytes = vec![0; PDF_HEADER_SEARCH_BYTES - 4];
347        bytes.extend_from_slice(b"%PDF-1.7\n");
348        let mut late = Source::new(bytes);
349        assert_eq!(detect(&mut late, &Limits::default()).unwrap(), None);
350        assert_eq!(late.reads.last(), Some(&(5, PDF_HEADER_SEARCH_BYTES - 5)));
351
352        let mut empty = Source::new(Vec::new());
353        assert_eq!(detect(&mut empty, &Limits::default()).unwrap(), None);
354        assert!(empty.reads.is_empty());
355    }
356
357    #[test]
358    fn invalid_limits_short_sources_and_cancellation_are_errors() {
359        let mut source = Source::new(b"%PDF-1.7".to_vec());
360        let zero_chunk = Limits {
361            io_chunk_bytes: 0,
362            ..Limits::default()
363        };
364        assert!(detect(&mut source, &zero_chunk).is_err());
365        assert!(source.reads.is_empty());
366
367        let mut short = Source::new(b"\n\n\n\n\n%PD".to_vec());
368        short.size = 64;
369        let error = detect(&mut short, &Limits::default()).unwrap_err();
370        assert!(
371            matches!(
372                error,
373                Error {
374                    kind: ErrorKind::Truncated { .. },
375                    offset: Some(5),
376                    ..
377                }
378            ),
379            "{error:?}"
380        );
381
382        let mut source = Source::new(b"%PDF-1.7".to_vec());
383        let error =
384            detect_source(&mut source, &Limits::default(), &CancelAfter::always()).unwrap_err();
385        assert!(
386            matches!(
387                error,
388                Error {
389                    kind: ErrorKind::Cancelled,
390                    ..
391                }
392            ),
393            "{error:?}"
394        );
395    }
396
397    const CAA: &[u8] = include_bytes!(concat!(
398        env!("CARGO_MANIFEST_DIR"),
399        "/../../tests/fixtures/target_descriptor.caa"
400    ));
401
402    #[test]
403    fn caa_requires_complete_observed_fields_within_the_probe_bound() {
404        let text = std::str::from_utf8(CAA).unwrap();
405        for bytes in [
406            CAA.to_vec(),
407            text.replace("DOCTYPE=NH", "DOCTYPE=KDH")
408                .replace('\n', "\r\n")
409                .into_bytes(),
410            [CAA, b"\r\n \t"].concat(),
411        ] {
412            assert_eq!(detect_format(&bytes), Some(InputFormat::Caa));
413        }
414        for end in 0..CAA.len() {
415            assert_eq!(detect_format(&CAA[..end]), None, "prefix {end}");
416        }
417        for (from, to) in [
418            ("[TARGET]", "[OTHER]"),
419            ("A1=1", "A1="),
420            ("D1=1", "D1=-1"),
421            ("A2=QQ==", "A2="),
422            ("A2=QQ==", "A2=Q!"),
423            ("D2=Qg==", "D2=你好"),
424            ("B1=0", "B1=1"),
425            ("C2=", "C2=unknown"),
426            ("B2=\n", ""),
427            ("A1=1", "A1=1\nA1=1"),
428            ("DOCTYPE=NH", "DOCTYPE=CAS"),
429            ("DOCTYPE=NH", "DOCTYPE=NHX"),
430        ] {
431            assert_eq!(
432                detect_format(text.replace(from, to).as_bytes()),
433                None,
434                "{to}"
435            );
436        }
437        assert_eq!(detect_format(&[CAA, b"X=extra\n"].concat()), None);
438        assert_eq!(detect_format(&[CAA, &[0xff]].concat()), None);
439        let long = text.replace("A2=QQ==", &format!("A2={}", "A".repeat(1024)));
440        assert_eq!(detect_format(long.as_bytes()), None);
441    }
442
443    #[test]
444    fn caa_detection_is_chunked_cancellable_and_bounded() {
445        let limits = Limits {
446            io_chunk_bytes: 3,
447            ..Limits::default()
448        };
449        let mut source = Source::new(CAA.to_vec());
450        assert_eq!(
451            detect(&mut source, &limits).unwrap(),
452            detection(InputFormat::Caa, 0, CAA.len() as u64)
453        );
454        assert!(source.reads.iter().all(|(_, len)| *len <= 3));
455        assert_eq!(
456            source.reads.iter().map(|(_, len)| len).sum::<usize>(),
457            CAA.len()
458        );
459        let error = detect_source(&mut source, &limits, &CancelAfter::new(2)).unwrap_err();
460        assert!(matches!(error.kind, ErrorKind::Cancelled));
461
462        let mut source = Source::new([b"[TARGET]\n".as_slice(), &[b'A'; 2048]].concat());
463        assert_eq!(detect(&mut source, &limits).unwrap(), None);
464        assert_eq!(
465            source.reads.iter().map(|(_, len)| len).sum::<usize>(),
466            PDF_HEADER_SEARCH_BYTES
467        );
468    }
469
470    #[test]
471    fn caa_inspection_has_unknown_counts_and_conversion_writes_nothing() {
472        let limits = Limits::default();
473        let mut source = CAA;
474        let info = super::inspect(
475            &mut source,
476            &super::InspectOptions::default(),
477            &limits,
478            &mut crate::NeverCancel,
479        )
480        .unwrap();
481        assert_eq!(info.format, InputFormat::Caa);
482        assert_eq!(info.page_count, None);
483        assert_eq!(info.bookmark_count, None);
484        assert_eq!(info.has_outline, None);
485        assert_eq!(info.input_bytes_read, CAA.len() as u64);
486        assert!(!info.format.is_convertible());
487        let mut output = Vec::new();
488        let error = super::convert(
489            &mut source,
490            &mut output,
491            super::ConversionOptions::default(),
492            &limits,
493            &mut crate::NeverCancel,
494        )
495        .unwrap_err();
496        assert!(matches!(error.kind, ErrorKind::UnsupportedFormat));
497        assert!(output.is_empty());
498    }
499}