Skip to main content

pdfboss_core/
linearization.rs

1//! The linearization parameter dictionary (ISO 32000-1 Annex F.3): the
2//! first object of a linearized file, read as data. Nothing here uses the
3//! hint streams or the first-page section to load pages early.
4
5use crate::object::Dict;
6use crate::parser::{NoResolve, Parser};
7
8/// The leading bytes a linearization parameter dictionary must lie within
9/// (ISO 32000-1 Annex F.3.3), so a reader decides whether a file is
10/// linearized from one small read.
11pub const LINEARIZATION_WINDOW: usize = 1024;
12
13/// The linearization parameter dictionary (ISO 32000-1 Annex F.3, Table
14/// F.1): the first object of a linearized file, whose entries locate the
15/// first page and the hint streams so a reader can show the first page
16/// before the whole file has arrived.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Linearization {
19    /// `/Linearized`: the version of the linearized format.
20    pub version: f64,
21    /// `/L`: the length of the file the dictionary was written for. When
22    /// it differs from the actual length the file is ordinary PDF; see
23    /// [`Linearization::is_current`].
24    pub file_length: u64,
25    /// `/H`: the offset and length of the primary hint stream and, when
26    /// present, of the overflow hint stream, as written.
27    pub hint_streams: Vec<(u64, u64)>,
28    /// `/O`: the object number of the first page's page object.
29    pub first_page_object: u32,
30    /// `/E`: the offset of the end of the first page.
31    pub first_page_end: u64,
32    /// `/N`: the number of pages in the document.
33    pub page_count: u32,
34    /// `/T`: the offset of the main cross-reference table's first entry
35    /// (of the main cross-reference stream in a file that uses streams).
36    pub main_xref_offset: u64,
37    /// `/P`: the page number of the first page, 0 when absent.
38    pub first_page: u32,
39}
40
41impl Linearization {
42    /// Whether `/L` equals the file's actual `file_length`. Table F.1 makes
43    /// a mismatch mean the file is not linearized and shall be read as
44    /// ordinary PDF, the usual cause being an appended update.
45    ///
46    /// Covers ISO 32000-1 Annex F.3.
47    pub fn is_current(&self, file_length: u64) -> bool {
48        self.file_length == file_length
49    }
50}
51
52/// Reads the linearization parameter dictionary from `head`, the first
53/// bytes of a file: the first indirect object of the body, which the annex
54/// requires to lie entirely within the first 1024 bytes. `None` when that
55/// object is not a dictionary carrying `/Linearized` or lacks one of Table
56/// F.1's required entries, so a file whose first object is anything else
57/// is simply not linearized.
58///
59/// Covers ISO 32000-1 Annex F.3.
60pub fn linearization_dictionary(head: &[u8]) -> Option<Linearization> {
61    let window = &head[..head.len().min(LINEARIZATION_WINDOW)];
62    let (_, object) = Parser::new(window).parse_indirect(&NoResolve).ok()?;
63    let dict = object.as_dict()?;
64    let version = dict.get("Linearized")?.as_f64()?;
65    let offset = |key: &str| u64::try_from(dict.get_int(key)?).ok();
66    let count = |key: &str| u32::try_from(dict.get_int(key)?).ok();
67    Some(Linearization {
68        version,
69        file_length: offset("L")?,
70        hint_streams: hint_streams(dict)?,
71        first_page_object: count("O")?,
72        first_page_end: offset("E")?,
73        page_count: count("N")?,
74        main_xref_offset: offset("T")?,
75        first_page: match dict.get_int("P") {
76            Some(page) => u32::try_from(page).ok()?,
77            None => 0,
78        },
79    })
80}
81
82/// The `/H` array as (offset, length) pairs: two or four integers, so an
83/// odd count or a non-integer reads as a malformed dictionary.
84fn hint_streams(dict: &Dict) -> Option<Vec<(u64, u64)>> {
85    let values: Vec<u64> = dict
86        .get_array("H")?
87        .iter()
88        .map(|value| u64::try_from(value.as_int()?).ok())
89        .collect::<Option<_>>()?;
90    let (pairs, rest) = values.as_chunks::<2>();
91    if pairs.is_empty() || !rest.is_empty() {
92        return None;
93    }
94    Some(
95        pairs
96            .iter()
97            .map(|&[offset, length]| (offset, length))
98            .collect(),
99    )
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::document::Document;
106    use pdfboss_testkit::PdfBuilder;
107
108    const HEAD: &[u8] = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n43 0 obj\n<< /Linearized 1 /L 12345 /H [ 500 200 ] /O 45 /E 3000 /N 3 /T 11000 /P 2 >>\nendobj\n44 0 obj\n<< /Type /Catalog >>\nendobj\n";
109
110    /// A one-page file whose first object is a linearization parameter
111    /// dictionary declaring `length` as the file length.
112    fn linearized_file(length: &str) -> Vec<u8> {
113        let mut b = PdfBuilder::new();
114        b.object(
115            43,
116            &format!("<< /Linearized 1 /L {length} /H [ 0 0 ] /O 46 /E 0 /N 1 /T 0 >>"),
117        );
118        b.object(44, "<< /Type /Catalog /Pages 45 0 R >>");
119        b.object(45, "<< /Type /Pages /Kids [46 0 R] /Count 1 >>");
120        b.object(46, "<< /Type /Page /Parent 45 0 R /MediaBox [0 0 10 10] >>");
121        b.build(44)
122    }
123
124    /// The same file with `/L` patched to its actual length; the
125    /// placeholder keeps the width, so the length does not move.
126    fn current_linearized_file() -> Vec<u8> {
127        let mut data = linearized_file("0000000000");
128        let length = format!("{:010}", data.len());
129        let at = data.windows(10).position(|w| w == b"0000000000").unwrap();
130        data[at..at + 10].copy_from_slice(length.as_bytes());
131        data
132    }
133
134    // Covers ISO 32000-1 Annex F.3.
135    #[test]
136    fn reads_every_entry_of_the_linearization_parameter_dictionary() {
137        let record = linearization_dictionary(HEAD).unwrap();
138        assert_eq!(
139            record,
140            Linearization {
141                version: 1.0,
142                file_length: 12345,
143                hint_streams: vec![(500, 200)],
144                first_page_object: 45,
145                first_page_end: 3000,
146                page_count: 3,
147                main_xref_offset: 11000,
148                first_page: 2,
149            }
150        );
151    }
152
153    // Covers ISO 32000-1 Annex F.3.
154    #[test]
155    fn the_first_page_number_defaults_to_zero_and_an_overflow_hint_stream_is_read() {
156        let head = b"%PDF-1.5\n7 0 obj\n<< /Linearized 1.0 /L 99 /H [ 500 200 8000 100 ] /O 9 /E 30 /N 1 /T 90 >>\nendobj\n";
157        let record = linearization_dictionary(head).unwrap();
158        assert_eq!(record.hint_streams, vec![(500, 200), (8000, 100)]);
159        assert_eq!(record.first_page, 0);
160        assert_eq!(record.version, 1.0);
161    }
162
163    /// Only the first object counts, it must sit within the first 1024
164    /// bytes, and every required entry of Table F.1 must be present.
165    // Covers ISO 32000-1 Annex F.3.
166    #[test]
167    fn a_file_without_the_dictionary_as_its_first_object_is_not_linearized() {
168        assert_eq!(linearization_dictionary(b""), None);
169        assert_eq!(
170            linearization_dictionary(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"),
171            None
172        );
173        let missing_page_count =
174            b"%PDF-1.4\n43 0 obj\n<< /Linearized 1 /L 12345 /H [ 500 200 ] /O 45 /E 3000 /T 11000 >>\nendobj\n";
175        assert_eq!(linearization_dictionary(missing_page_count), None);
176        let odd_hint_array =
177            b"%PDF-1.4\n43 0 obj\n<< /Linearized 1 /L 12345 /H [ 500 ] /O 45 /E 3000 /N 3 /T 11000 >>\nendobj\n";
178        assert_eq!(linearization_dictionary(odd_hint_array), None);
179        let mut late = b"%PDF-1.4\n%".to_vec();
180        late.extend(std::iter::repeat_n(b'x', 1024));
181        late.extend_from_slice(&HEAD[9..]);
182        assert_eq!(linearization_dictionary(&late), None);
183    }
184
185    /// `/L` must equal the file's length for the linearization information
186    /// to apply; after an appended update the dictionary is still readable
187    /// but the file counts as ordinary PDF.
188    // Covers ISO 32000-1 Annex F.3.
189    #[test]
190    fn a_document_is_linearized_only_while_the_declared_length_matches() {
191        let doc = Document::load(current_linearized_file()).unwrap();
192        let record = doc.linearization().unwrap();
193        assert_eq!(record.first_page_object, 46);
194        assert_eq!(record.page_count, 1);
195        assert!(doc.is_linearized());
196
197        let mut updated = current_linearized_file();
198        updated.extend_from_slice(b"%appended update\n");
199        let doc = Document::load(updated).unwrap();
200        assert!(doc.linearization().is_some());
201        assert!(!doc.is_linearized());
202
203        let doc = Document::load(pdfboss_testkit::simple_doc("plain")).unwrap();
204        assert_eq!(doc.linearization(), None);
205        assert!(!doc.is_linearized());
206    }
207}