Skip to main content

oxideav_pdf/reader/
linearize.rs

1//! Round-27 — Linearization Parameter Dictionary reader (ISO 32000-1
2//! §F.2 + Annex F.3).
3//!
4//! Round 9 added the writer-side emission of linearized ("Fast Web
5//! View") PDFs in [`crate::linearize`]; this module is the read-side
6//! complement. The linearization parameter dictionary is the FIRST
7//! indirect object in a linearized file (§F.3.3: "shall be entirely
8//! contained within the first 1024 bytes of the PDF file") and carries
9//! the structural offsets a streaming reader needs to fetch + render
10//! the first page without downloading the rest:
11//!
12//! | Key  | Type    | Required | Meaning                                                |
13//! |------|---------|----------|--------------------------------------------------------|
14//! | `/Linearized` | number  | yes | Version number — always `1` for Annex F.        |
15//! | `/L`          | integer | yes | Total file length in bytes.                     |
16//! | `/H`          | array   | yes | Primary hint-stream `[offset length]` pair      |
17//! | `/O`          | integer | yes | Object number of the first page's `/Page` dict. |
18//! | `/E`          | integer | yes | Byte offset of the end of the first-page section. |
19//! | `/N`          | integer | yes | Total page count in the document.               |
20//! | `/T`          | integer | yes | Byte offset of the main cross-reference section.|
21//!
22//! Two design notes:
23//!
24//! * **Plain (non-linearized) files are NOT errors** — the parser
25//!   returns `Ok(None)` when the file's first object isn't a
26//!   linearization parameter dictionary, so callers can branch on the
27//!   `Option` without a try/catch dance.
28//! * **No `/Linearized` value coercion** — the spec leaves the version
29//!   as "a number" rather than fixing it at `1`. We surface the value
30//!   verbatim (as an `f64`) so future versions parse without changes.
31//!
32//! Scope: parse only. Hint-table decoding (Annex F.4 — the
33//! page-offset + shared-object + thumbnail + outline tables packed
34//! inside the hint stream) is out of scope; a downstream tool that
35//! actually streams the file from network would consume those, but
36//! the parameter dict alone is what tells a viewer the file is
37//! linearized and where the main xref lives. Hint-table decoders are
38//! a round-28+ follow-up.
39
40use crate::error::PdfError;
41use crate::objects::{Dict, Object};
42use crate::reader::parse::Parser;
43
44/// Parsed `/Linearized` parameter dictionary per ISO 32000-1 §F.2.
45///
46/// Returned by [`parse_linearization_dict`] (and from
47/// [`crate::reader::DocumentReader::linearization`]). All field names
48/// match the PDF dict keys exactly; the `linearized` field is the
49/// numeric version (always `1.0` for current Annex F).
50#[derive(Debug, Clone, PartialEq)]
51pub struct LinearizationParams {
52    /// `/Linearized` — the version number (always `1` in published
53    /// PDF specs; surfaced as `f64` so the parser tolerates the
54    /// theoretical "future version" case).
55    pub linearized: f64,
56    /// `/L` — total file length in bytes. Must equal `input.len()`
57    /// for a non-truncated linearized file; the parser does NOT
58    /// enforce this (callers may want to surface a mismatch as a
59    /// "truncated" diagnostic rather than a hard parse error).
60    pub file_length: u64,
61    /// `/H` — primary hint stream `[offset, length]` (the first two
62    /// integers in the `/H` array). Tables F.3 / F.4 / F.5 / F.6
63    /// / F.7 live inside this hint stream. Some files emit
64    /// `[off1 len1 off2 len2]` for split hint streams; we surface
65    /// only the first pair (the mandatory primary).
66    pub hint_offset: u64,
67    pub hint_length: u64,
68    /// `/H` overflow / secondary hint stream `[offset, length]`,
69    /// when present. Optional per §F.2.
70    pub hint_overflow: Option<(u64, u64)>,
71    /// `/O` — object number of the first page's `/Page` indirect
72    /// object. Section F.3.6 anchors the first-page section at this
73    /// indirect object's byte offset.
74    pub first_page_object_number: u32,
75    /// `/E` — byte offset of the end of the first-page section
76    /// (one past the last byte of the first page's contents
77    /// stream). A streaming reader can stop downloading once it has
78    /// `[0, E)` and `[main_xref_off, file_length)`.
79    pub end_of_first_page: u64,
80    /// `/N` — total page count in the document.
81    pub page_count: u32,
82    /// `/T` — byte offset of the main cross-reference section
83    /// (the one referenced by `startxref` in non-linearized files).
84    /// `startxref` at the end of a linearized file actually points
85    /// at the first-page xref; `/T` is the way to find the main one.
86    pub main_xref_offset: u64,
87}
88
89impl LinearizationParams {
90    /// Try to parse the linearization parameter dictionary from the
91    /// start of a PDF file.
92    ///
93    /// Returns `Ok(None)` when:
94    /// * The file is too short to contain a linearization parameter
95    ///   dict (< 16 bytes; the spec requires the first 1024 bytes).
96    /// * The first indirect object isn't a dictionary, or it is a
97    ///   dictionary but doesn't carry `/Linearized`.
98    ///
99    /// Returns `Err(PdfError::Other)` when `/Linearized` IS present
100    /// but a required key is missing or malformed — the file
101    /// declares itself linearized but doesn't honour the contract.
102    pub fn parse(input: &[u8]) -> Result<Option<Self>, PdfError> {
103        parse_linearization_dict(input)
104    }
105
106    /// Verify the parameter dict matches the actual file bytes —
107    /// `/L` equals `input.len()` and `/T` points within bounds.
108    /// Returns `Ok(())` when consistent; `Err` lists the first
109    /// mismatch. Pure diagnostic — `parse` accepts malformed
110    /// values without consulting the file bytes.
111    pub fn verify(&self, input: &[u8]) -> Result<(), PdfError> {
112        if self.file_length != input.len() as u64 {
113            return Err(PdfError::other(format!(
114                "PDF linearization: /L = {} but file is {} bytes (truncated or extended?)",
115                self.file_length,
116                input.len()
117            )));
118        }
119        if self.main_xref_offset >= input.len() as u64 {
120            return Err(PdfError::other(format!(
121                "PDF linearization: /T = {} points past end of file ({} bytes)",
122                self.main_xref_offset,
123                input.len()
124            )));
125        }
126        if self.end_of_first_page > input.len() as u64 {
127            return Err(PdfError::other(format!(
128                "PDF linearization: /E = {} points past end of file ({} bytes)",
129                self.end_of_first_page,
130                input.len()
131            )));
132        }
133        if self.hint_offset >= input.len() as u64 {
134            return Err(PdfError::other(format!(
135                "PDF linearization: /H[0] = {} points past end of file ({} bytes)",
136                self.hint_offset,
137                input.len()
138            )));
139        }
140        if self.hint_offset + self.hint_length > input.len() as u64 {
141            return Err(PdfError::other(format!(
142                "PDF linearization: /H stream extends past end of file ({} + {} > {})",
143                self.hint_offset,
144                self.hint_length,
145                input.len()
146            )));
147        }
148        if self.page_count == 0 {
149            return Err(PdfError::other(
150                "PDF linearization: /N = 0 — linearized file must declare ≥1 page",
151            ));
152        }
153        Ok(())
154    }
155}
156
157/// Parse the linearization parameter dictionary, if present.
158///
159/// Per §F.3.3 the parameter dictionary lives within the first 1024
160/// bytes of the file. We scan only that prefix to keep the
161/// non-linearized fast path cheap.
162pub fn parse_linearization_dict(input: &[u8]) -> Result<Option<LinearizationParams>, PdfError> {
163    // §F.3.3: lin-dict is entirely within the first 1024 bytes. We
164    // accept a bit more slack (up to 2048) so a marginally bloated
165    // dict still parses — the worst case is a writer that pads
166    // /L /H /T integers to 10 digits each.
167    if input.len() < 16 {
168        return Ok(None);
169    }
170    let scan_end = 2048.min(input.len());
171    let head = &input[..scan_end];
172
173    // Locate the first `obj` keyword. Skipping the `%PDF-x.y` header
174    // and any binary marker comment, the first byte-offset that
175    // tokenises as an indirect object header is where the
176    // linearization param dict lives.
177    let obj_pos = match find_first_obj_header(head) {
178        Some(p) => p,
179        None => return Ok(None),
180    };
181
182    let mut p = Parser::new(input);
183    p.lexer_mut().seek(obj_pos);
184    // parse_indirect requires `<n> <gen> obj`. The first object in a
185    // linearized file is always 1-generation-0, but we don't enforce
186    // that — the spec only fixes the lin-dict's *position*, not its
187    // object number.
188    let (_id, body) = match p.parse_indirect() {
189        Ok(v) => v,
190        Err(_) => return Ok(None),
191    };
192    let Object::Dict(d) = body else {
193        return Ok(None);
194    };
195
196    // Not a lin-dict if it lacks /Linearized — that's the only key
197    // that distinguishes a lin-dict from any other PDF dict (eg. a
198    // first-object Catalog if the writer skipped linearization).
199    let Some(_) = lookup(&d, "Linearized") else {
200        return Ok(None);
201    };
202
203    // Now require every key. Per §F.2 they're all REQ.
204    let linearized = require_number(&d, "Linearized")?;
205    let file_length = require_uint(&d, "L")?;
206    let (hint_offset, hint_length, hint_overflow) = require_hint_array(&d)?;
207    let first_page_object_number = require_uint(&d, "O")? as u32;
208    let end_of_first_page = require_uint(&d, "E")?;
209    let page_count = require_uint(&d, "N")? as u32;
210    let main_xref_offset = require_uint(&d, "T")?;
211
212    Ok(Some(LinearizationParams {
213        linearized,
214        file_length,
215        hint_offset,
216        hint_length,
217        hint_overflow,
218        first_page_object_number,
219        end_of_first_page,
220        page_count,
221        main_xref_offset,
222    }))
223}
224
225/// Locate the first `<n> <gen> obj` indirect-object header in
226/// `head`. We scan for the literal `" obj"` keyword and walk
227/// backwards past two non-negative integers — cheaper than running
228/// the full lexer over the binary marker comment that immediately
229/// follows the `%PDF-x.y` header line.
230fn find_first_obj_header(head: &[u8]) -> Option<usize> {
231    let needle = b" obj";
232    let mut search = 0usize;
233    while let Some(rel) = window_find(&head[search..], needle) {
234        let pos = search + rel;
235        // Walk back over two whitespace-separated integers. If the
236        // bytes immediately before " obj" match `<gen-digit>+
237        // <space> <n-digit>+`, the integer just before the second
238        // whitespace gap is the indirect object number.
239        let header_start = match scan_back_two_ints(head, pos) {
240            Some(p) => p,
241            None => {
242                search = pos + needle.len();
243                continue;
244            }
245        };
246        return Some(header_start);
247    }
248    None
249}
250
251/// Substring search using the standard `windows().position()` form.
252fn window_find(hay: &[u8], needle: &[u8]) -> Option<usize> {
253    if needle.is_empty() || hay.len() < needle.len() {
254        return None;
255    }
256    hay.windows(needle.len()).position(|w| w == needle)
257}
258
259/// Walk back from `space_before_obj_pos` (the position of the ` `
260/// in ` obj`) over `<gen> <space> <n>` and return the byte offset of
261/// the first digit of `<n>`. The lexer can re-anchor there and parse
262/// the full `<n> <gen> obj` indirect-object header.
263fn scan_back_two_ints(input: &[u8], space_before_obj_pos: usize) -> Option<usize> {
264    // Walk back over `<gen>` digits.
265    let mut p = space_before_obj_pos;
266    if p == 0 {
267        return None;
268    }
269    p -= 1;
270    while p > 0 && input[p].is_ascii_digit() {
271        p -= 1;
272    }
273    // Either we hit a non-digit (the space between `<n>` and `<gen>`)
274    // or we hit the start of input.
275    if !input[p].is_ascii_whitespace() {
276        return None;
277    }
278    // Walk back through the whitespace.
279    while p > 0 && input[p].is_ascii_whitespace() {
280        p -= 1;
281    }
282    // Walk back over `<n>` digits.
283    if !input[p].is_ascii_digit() {
284        return None;
285    }
286    while p > 0 && input[p].is_ascii_digit() {
287        p -= 1;
288    }
289    // The byte at `p` is now either a digit (if `<n>` ran to the
290    // file start) or a whitespace / EOL byte just before `<n>`.
291    if input[p].is_ascii_digit() {
292        Some(p)
293    } else {
294        Some(p + 1)
295    }
296}
297
298fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
299    d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
300}
301
302fn require_number(d: &Dict, k: &str) -> Result<f64, PdfError> {
303    match lookup(d, k) {
304        Some(Object::Integer(n)) => Ok(*n as f64),
305        Some(Object::Real(f)) => Ok(*f),
306        Some(other) => Err(PdfError::other(format!(
307            "PDF linearization: /{k} must be a number (got {other:?})"
308        ))),
309        None => Err(PdfError::other(format!(
310            "PDF linearization: missing required /{k}"
311        ))),
312    }
313}
314
315fn require_uint(d: &Dict, k: &str) -> Result<u64, PdfError> {
316    match lookup(d, k) {
317        Some(Object::Integer(n)) if *n >= 0 => Ok(*n as u64),
318        Some(Object::Integer(n)) => Err(PdfError::other(format!(
319            "PDF linearization: /{k} must be non-negative (got {n})"
320        ))),
321        Some(other) => Err(PdfError::other(format!(
322            "PDF linearization: /{k} must be an integer (got {other:?})"
323        ))),
324        None => Err(PdfError::other(format!(
325            "PDF linearization: missing required /{k}"
326        ))),
327    }
328}
329
330/// `/H` is `[off len]` or `[off len off2 len2]`. We require the
331/// first pair and surface the second pair when present.
332#[allow(clippy::type_complexity)]
333fn require_hint_array(d: &Dict) -> Result<(u64, u64, Option<(u64, u64)>), PdfError> {
334    let Some(obj) = lookup(d, "H") else {
335        return Err(PdfError::other("PDF linearization: missing required /H"));
336    };
337    let Object::Array(items) = obj else {
338        return Err(PdfError::other(format!(
339            "PDF linearization: /H must be an array (got {obj:?})"
340        )));
341    };
342    if items.len() < 2 || items.len() % 2 != 0 {
343        return Err(PdfError::other(format!(
344            "PDF linearization: /H must have 2 or 4 elements (got {})",
345            items.len()
346        )));
347    }
348    let as_uint = |o: &Object, ix: usize| -> Result<u64, PdfError> {
349        match o {
350            Object::Integer(n) if *n >= 0 => Ok(*n as u64),
351            _ => Err(PdfError::other(format!(
352                "PDF linearization: /H[{ix}] must be a non-negative integer (got {o:?})"
353            ))),
354        }
355    };
356    let off = as_uint(&items[0], 0)?;
357    let len = as_uint(&items[1], 1)?;
358    let overflow = if items.len() >= 4 {
359        Some((as_uint(&items[2], 2)?, as_uint(&items[3], 3)?))
360    } else {
361        None
362    };
363    Ok((off, len, overflow))
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::linearize::write_pdf_linearized;
370    use crate::writer::write_pdf_from_scene;
371    use oxideav_core::time::TimeBase;
372    use oxideav_core::vector::{
373        FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
374    };
375    use oxideav_scene::{Page, Scene};
376
377    fn rect_frame(w: f32, h: f32, color: Rgba) -> VectorFrame {
378        let mut p = Path::new();
379        p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
380        p.commands
381            .push(PathCommand::LineTo(Point::new(w - 10.0, 10.0)));
382        p.commands
383            .push(PathCommand::LineTo(Point::new(w - 10.0, h - 10.0)));
384        p.commands
385            .push(PathCommand::LineTo(Point::new(10.0, h - 10.0)));
386        p.commands.push(PathCommand::Close);
387        VectorFrame {
388            width: w,
389            height: h,
390            view_box: None,
391            root: Group {
392                children: vec![Node::Path(PathNode {
393                    path: p,
394                    fill: Some(Paint::Solid(color)),
395                    stroke: None,
396                    fill_rule: FillRule::NonZero,
397                })],
398                ..Group::default()
399            },
400            pts: None,
401            time_base: TimeBase::new(1, 1),
402        }
403    }
404
405    fn page_with(w: f32, h: f32, color: Rgba) -> Page {
406        let mut page = Page::new(w, h);
407        page.content = rect_frame(w, h, color);
408        page
409    }
410
411    fn linearized_scene_3_pages() -> Vec<u8> {
412        let scene = Scene {
413            pages: Some(vec![
414                page_with(100.0, 100.0, Rgba::opaque(255, 0, 0)),
415                page_with(200.0, 150.0, Rgba::opaque(0, 255, 0)),
416                page_with(300.0, 200.0, Rgba::opaque(0, 0, 255)),
417            ]),
418            ..Scene::default()
419        };
420        write_pdf_linearized(&scene).expect("linearize")
421    }
422
423    #[test]
424    fn parses_linearization_dict_from_writer_output() {
425        let pdf = linearized_scene_3_pages();
426        let lin = LinearizationParams::parse(&pdf)
427            .expect("parse")
428            .expect("Some");
429        assert_eq!(lin.linearized, 1.0);
430        assert_eq!(lin.file_length, pdf.len() as u64);
431        assert_eq!(lin.page_count, 3);
432    }
433
434    #[test]
435    fn parsed_linearization_main_xref_actually_holds_xref() {
436        let pdf = linearized_scene_3_pages();
437        let lin = LinearizationParams::parse(&pdf)
438            .expect("parse")
439            .expect("Some");
440        // The byte sequence at `main_xref_offset` should start with `xref\n`.
441        let off = lin.main_xref_offset as usize;
442        assert_eq!(
443            &pdf[off..off + 5],
444            b"xref\n",
445            "/T must point at the main xref section"
446        );
447    }
448
449    #[test]
450    fn parsed_linearization_first_page_object_at_byte_offset_matches() {
451        let pdf = linearized_scene_3_pages();
452        let lin = LinearizationParams::parse(&pdf)
453            .expect("parse")
454            .expect("Some");
455        // The first-page object number must appear as a `<n> 0 obj`
456        // somewhere in the file. Round-9 emits page 1 as a header
457        // `<O> 0 obj` — search for it.
458        let needle = format!("{} 0 obj", lin.first_page_object_number);
459        let pos = pdf
460            .windows(needle.len())
461            .position(|w| w == needle.as_bytes())
462            .expect("first-page obj header present");
463        assert!(pos > 0);
464    }
465
466    #[test]
467    fn verify_succeeds_for_writer_output() {
468        let pdf = linearized_scene_3_pages();
469        let lin = LinearizationParams::parse(&pdf)
470            .expect("parse")
471            .expect("Some");
472        lin.verify(&pdf).expect("verify clean");
473    }
474
475    #[test]
476    fn verify_fails_when_l_mismatches_actual_length() {
477        let pdf = linearized_scene_3_pages();
478        let mut lin = LinearizationParams::parse(&pdf)
479            .expect("parse")
480            .expect("Some");
481        lin.file_length += 1;
482        let err = lin.verify(&pdf).expect_err("must reject");
483        let msg = format!("{err}");
484        assert!(msg.contains("/L = "), "msg = {msg:?}");
485    }
486
487    #[test]
488    fn non_linearized_pdf_returns_none() {
489        let scene = Scene {
490            pages: Some(vec![page_with(100.0, 100.0, Rgba::opaque(0, 0, 0))]),
491            ..Scene::default()
492        };
493        let pdf = write_pdf_from_scene(&scene).expect("write");
494        // The first object is the Catalog, not a /Linearized param dict.
495        let lin = LinearizationParams::parse(&pdf).expect("parse");
496        assert!(
497            lin.is_none(),
498            "non-linearized PDF must parse to None, got {lin:?}"
499        );
500    }
501
502    #[test]
503    fn empty_input_returns_none() {
504        assert!(LinearizationParams::parse(b"").expect("parse").is_none());
505        assert!(LinearizationParams::parse(b"%PDF-1.7\n%%EOF\n")
506            .expect("parse")
507            .is_none());
508    }
509
510    #[test]
511    fn malformed_input_returns_none() {
512        // No `obj` keyword anywhere — parser returns None, not Err.
513        let stub = b"%PDF-1.5\n\xE2\xE3\xCF\xD3 no obj here at all\n%%EOF\n";
514        assert!(LinearizationParams::parse(stub).expect("parse").is_none());
515    }
516
517    #[test]
518    fn rejects_lin_dict_missing_required_key() {
519        // Hand-rolled minimal first object that LOOKS like a
520        // lin-dict (has /Linearized 1) but is missing /L.
521        let mut bytes = Vec::new();
522        bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
523        bytes.extend_from_slice(b"1 0 obj\n<< /Linearized 1 >>\nendobj\n");
524        bytes.extend_from_slice(
525            b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
526        );
527        let err = LinearizationParams::parse(&bytes).expect_err("must reject");
528        let msg = format!("{err}");
529        assert!(msg.contains("/L"), "msg = {msg:?}");
530    }
531
532    #[test]
533    fn rejects_hint_array_with_odd_length() {
534        let mut bytes = Vec::new();
535        bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
536        bytes.extend_from_slice(
537            b"1 0 obj\n<< /Linearized 1 /L 100 /H [ 50 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
538        );
539        bytes.extend_from_slice(
540            b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
541        );
542        let err = LinearizationParams::parse(&bytes).expect_err("must reject /H length");
543        let msg = format!("{err}");
544        assert!(msg.contains("/H"), "msg = {msg:?}");
545    }
546
547    #[test]
548    fn accepts_hint_array_with_four_elements() {
549        let mut bytes = Vec::new();
550        bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
551        bytes.extend_from_slice(
552            b"1 0 obj\n<< /Linearized 1 /L 200 /H [ 50 30 100 20 ] /O 2 /E 80 /N 1 /T 180 >>\nendobj\n",
553        );
554        bytes.extend_from_slice(
555            b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
556        );
557        let lin = LinearizationParams::parse(&bytes)
558            .expect("parse")
559            .expect("Some");
560        assert_eq!(lin.hint_offset, 50);
561        assert_eq!(lin.hint_length, 30);
562        assert_eq!(lin.hint_overflow, Some((100, 20)));
563    }
564
565    #[test]
566    fn rejects_lin_dict_with_negative_int() {
567        let mut bytes = Vec::new();
568        bytes.extend_from_slice(b"%PDF-1.5\n%\xE2\xE3\xCF\xD3\n");
569        bytes.extend_from_slice(
570            b"1 0 obj\n<< /Linearized 1 /L -1 /H [ 50 30 ] /O 2 /E 80 /N 1 /T 90 >>\nendobj\n",
571        );
572        bytes.extend_from_slice(
573            b"xref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
574        );
575        let err = LinearizationParams::parse(&bytes).expect_err("must reject /L=-1");
576        let msg = format!("{err}");
577        assert!(msg.contains("/L"), "msg = {msg:?}");
578    }
579
580    #[test]
581    fn scan_back_two_ints_finds_header_start() {
582        // `   1 0 obj\n<<` — the call site supplies the position of
583        // the space immediately before "obj"; helper should return
584        // the offset of `1`.
585        let buf = b"   1 0 obj\n<<";
586        let space_pos = buf
587            .windows(b" obj".len())
588            .position(|w| w == b" obj")
589            .unwrap();
590        let start = scan_back_two_ints(buf, space_pos).expect("found");
591        assert_eq!(&buf[start..start + 1], b"1");
592    }
593}