Skip to main content

otf_pdf_syntax/
header.rs

1//! The `%PDF-M.m` file header.
2
3use std::fmt;
4
5use crate::source::{Source, SourceError};
6
7/// How far into a file the header is looked for.
8///
9/// ISO 32000 puts the header at byte 0, but real files carry junk before it
10/// (mail headers, BOMs, printer job language). Acrobat accepts a header that
11/// starts anywhere in the first 1024 bytes; so do we.
12pub const HEADER_SEARCH_WINDOW: u64 = 1024;
13
14const MARKER: &[u8] = b"%PDF-";
15/// Digits per version component; more than this is not a version.
16const MAX_DIGITS: usize = 3;
17/// Bytes read past the window so a header starting near its end is whole.
18const TAIL: usize = MARKER.len() + 2 * MAX_DIGITS + 1;
19
20/// A PDF version as written in the header, e.g. `1.7`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct Version {
23    /// The part before the dot.
24    pub major: u8,
25    /// The part after the dot.
26    pub minor: u8,
27}
28
29impl Version {
30    /// PDF 1.7 (ISO 32000-1), the default output version.
31    pub const V1_7: Version = Version::new(1, 7);
32    /// PDF 2.0 (ISO 32000-2).
33    pub const V2_0: Version = Version::new(2, 0);
34
35    /// A version from its two components.
36    pub const fn new(major: u8, minor: u8) -> Self {
37        Self { major, minor }
38    }
39
40    /// Whether this is a published PDF version: 1.0 to 1.7, or 2.0.
41    pub fn is_known(self) -> bool {
42        matches!((self.major, self.minor), (1, 0..=7) | (2, 0))
43    }
44}
45
46impl fmt::Display for Version {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}.{}", self.major, self.minor)
49    }
50}
51
52/// A located file header.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Header {
55    /// Byte offset of the `%` that starts the header. Non-zero means there is
56    /// junk before it; byte offsets inside the file (the xref, `startxref`)
57    /// are usually relative to this point in such files.
58    pub offset: u64,
59    /// The version the header declares. It may be unknown (see
60    /// [`Version::is_known`]); the catalog's `/Version` can also override it.
61    pub version: Version,
62}
63
64impl Header {
65    /// Whether bytes precede the header.
66    pub fn has_leading_junk(&self) -> bool {
67        self.offset != 0
68    }
69}
70
71/// Why no header could be read.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum HeaderError {
74    /// No `%PDF-` within the first [`HEADER_SEARCH_WINDOW`] bytes.
75    NotFound,
76    /// `%PDF-` was found at `offset` but no `major.minor` version follows it.
77    MalformedVersion {
78        /// Byte offset of the `%PDF-` marker.
79        offset: u64,
80    },
81    /// The source failed to serve the read.
82    Source(SourceError),
83}
84
85impl fmt::Display for HeaderError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::NotFound => write!(
89                f,
90                "no %PDF- header in the first {HEADER_SEARCH_WINDOW} bytes"
91            ),
92            Self::MalformedVersion { offset } => {
93                write!(f, "malformed version after %PDF- at byte {offset}")
94            }
95            Self::Source(err) => err.fmt(f),
96        }
97    }
98}
99
100impl std::error::Error for HeaderError {
101    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102        match self {
103            Self::Source(err) => Some(err),
104            _ => None,
105        }
106    }
107}
108
109impl From<SourceError> for HeaderError {
110    fn from(err: SourceError) -> Self {
111        Self::Source(err)
112    }
113}
114
115/// Finds the `%PDF-M.m` header in the first [`HEADER_SEARCH_WINDOW`] bytes.
116///
117/// The first `%PDF-` marker wins; if no version follows it, that is an error
118/// rather than a reason to keep looking, since a second header in the junk is
119/// no more trustworthy than the first.
120pub fn find_header<S: Source + ?Sized>(source: &S) -> Result<Header, HeaderError> {
121    // The window and its tail are a little over 1 KiB, so this always fits.
122    let mut buf = [0u8; HEADER_SEARCH_WINDOW as usize + TAIL];
123    let mut filled = 0;
124    while filled < buf.len() {
125        let Some(rest) = buf.get_mut(filled..) else {
126            break;
127        };
128        let n = source.read_at(filled as u64, rest)?;
129        if n == 0 {
130            break;
131        }
132        filled += n;
133    }
134    let bytes = buf.get(..filled).unwrap_or_default();
135
136    let start = bytes
137        .windows(MARKER.len())
138        .take(HEADER_SEARCH_WINDOW as usize)
139        .position(|w| w == MARKER)
140        .ok_or(HeaderError::NotFound)?;
141    let offset = start as u64;
142    let after = bytes
143        .get(start + MARKER.len()..)
144        .ok_or(HeaderError::MalformedVersion { offset })?;
145    let version = parse_version(after).ok_or(HeaderError::MalformedVersion { offset })?;
146    Ok(Header { offset, version })
147}
148
149/// Parses `major.minor` from the start of `bytes`.
150fn parse_version(bytes: &[u8]) -> Option<Version> {
151    let (major, rest) = parse_component(bytes)?;
152    let rest = rest.strip_prefix(b".")?;
153    let (minor, _) = parse_component(rest)?;
154    Some(Version::new(major, minor))
155}
156
157/// Parses one to [`MAX_DIGITS`] ASCII digits that fit in a `u8`.
158fn parse_component(bytes: &[u8]) -> Option<(u8, &[u8])> {
159    let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
160    if digits == 0 || digits > MAX_DIGITS {
161        return None;
162    }
163    let (number, rest) = bytes.split_at_checked(digits)?;
164    let value = number
165        .iter()
166        .try_fold(0u16, |acc, &d| Some(acc * 10 + u16::from(d - b'0')))?;
167    Some((u8::try_from(value).ok()?, rest))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn header(bytes: &[u8]) -> Result<Header, HeaderError> {
175        find_header(bytes)
176    }
177
178    #[test]
179    fn header_at_start() {
180        assert_eq!(
181            header(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"),
182            Ok(Header {
183                offset: 0,
184                version: Version::V1_7
185            })
186        );
187    }
188
189    #[test]
190    fn pdf_2_0() {
191        let h = header(b"%PDF-2.0\r\n").unwrap();
192        assert_eq!(h.version, Version::V2_0);
193        assert!(h.version.is_known());
194        assert!(!h.has_leading_junk());
195    }
196
197    #[test]
198    fn junk_before_header() {
199        let mut bytes = vec![b'x'; 500];
200        bytes.extend_from_slice(b"%PDF-1.4\n");
201        let h = header(&bytes).unwrap();
202        assert_eq!(h.offset, 500);
203        assert!(h.has_leading_junk());
204        assert_eq!(h.version, Version::new(1, 4));
205    }
206
207    #[test]
208    fn header_starting_at_last_window_byte() {
209        let mut bytes = vec![0u8; HEADER_SEARCH_WINDOW as usize - 1];
210        bytes.extend_from_slice(b"%PDF-1.3");
211        assert_eq!(header(&bytes).unwrap().offset, HEADER_SEARCH_WINDOW - 1);
212    }
213
214    #[test]
215    fn header_past_window_is_not_found() {
216        let mut bytes = vec![0u8; HEADER_SEARCH_WINDOW as usize];
217        bytes.extend_from_slice(b"%PDF-1.3");
218        assert_eq!(header(&bytes), Err(HeaderError::NotFound));
219    }
220
221    #[test]
222    fn empty_and_unrelated_input() {
223        assert_eq!(header(b""), Err(HeaderError::NotFound));
224        assert_eq!(header(b"%!PS-Adobe-3.0"), Err(HeaderError::NotFound));
225        assert_eq!(header(b"%PDF"), Err(HeaderError::NotFound));
226    }
227
228    #[test]
229    fn malformed_versions() {
230        for bad in [
231            &b"%PDF-"[..],
232            b"%PDF-x.y",
233            b"%PDF-1",
234            b"%PDF-1.",
235            b"%PDF-.7",
236            b"%PDF-1234.0",
237            b"%PDF-256.0",
238            b"%PDF-1.9999",
239        ] {
240            assert_eq!(
241                header(bad),
242                Err(HeaderError::MalformedVersion { offset: 0 }),
243                "{}",
244                String::from_utf8_lossy(bad)
245            );
246        }
247    }
248
249    #[test]
250    fn first_marker_wins_even_if_malformed() {
251        assert_eq!(
252            header(b"junk%PDF-?\n%PDF-1.7"),
253            Err(HeaderError::MalformedVersion { offset: 4 })
254        );
255    }
256
257    #[test]
258    fn unknown_but_well_formed_version() {
259        let h = header(b"%PDF-1.8").unwrap();
260        assert_eq!(h.version, Version::new(1, 8));
261        assert!(!h.version.is_known());
262        assert!(!Version::new(2, 1).is_known());
263        assert!(!Version::new(0, 9).is_known());
264        assert!(Version::new(1, 0).is_known());
265    }
266
267    #[test]
268    fn three_digit_components() {
269        assert_eq!(
270            header(b"%PDF-001.255").unwrap().version,
271            Version::new(1, 255)
272        );
273    }
274
275    #[test]
276    fn version_display() {
277        assert_eq!(Version::V1_7.to_string(), "1.7");
278        assert_eq!(Version::new(10, 12).to_string(), "10.12");
279    }
280
281    #[test]
282    fn error_display() {
283        assert_eq!(
284            HeaderError::NotFound.to_string(),
285            "no %PDF- header in the first 1024 bytes"
286        );
287        assert_eq!(
288            HeaderError::MalformedVersion { offset: 3 }.to_string(),
289            "malformed version after %PDF- at byte 3"
290        );
291    }
292
293    /// A source that serves one byte per read, to exercise short reads.
294    struct Trickle<'a>(&'a [u8]);
295
296    impl Source for Trickle<'_> {
297        fn len(&self) -> u64 {
298            self.0.len() as u64
299        }
300
301        fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<usize, SourceError> {
302            let end = buf.len().min(1);
303            self.0.read_at(offset, &mut buf[..end])
304        }
305    }
306
307    #[test]
308    fn short_reads_are_retried() {
309        let mut bytes = vec![b' '; 10];
310        bytes.extend_from_slice(b"%PDF-1.5");
311        assert_eq!(find_header(&Trickle(&bytes)).unwrap().offset, 10);
312    }
313
314    struct Failing;
315
316    impl Source for Failing {
317        fn len(&self) -> u64 {
318            100
319        }
320
321        fn read_at(&self, _: u64, _: &mut [u8]) -> Result<usize, SourceError> {
322            Err(SourceError::new("boom"))
323        }
324    }
325
326    #[test]
327    fn source_errors_propagate() {
328        let err = find_header(&Failing).unwrap_err();
329        assert_eq!(err, HeaderError::Source(SourceError::new("boom")));
330        assert!(std::error::Error::source(&err).is_some());
331        assert_eq!(err.to_string(), "source read failed: boom");
332    }
333}