Repository: J-F-Liu/lopdf (2026-03-19T07:13:45Z)

Issue:
Accept binary bytes on the PDF header line
I have a pdf  [vittel_rdc.pdf](https://github.com/user-attachments/files/26108134/vittel_rdc.pdf) that fails to load in lopdf with
```
Error: Parse(invalid file header)
```
but otherwise opens fine in other pdf viewers.

After some investigations it looks like it has a header with something like
```
%PDF-1.3 \xb0\x9f\x92\x9c\x9f\xd4\xe0\xce\xd0\xd0\xd0\r
```
and was produced by ImageMill Imaging Library v3.000.

If I understand correctly, the pdf spec (ISO 32000, §7.5.2) would requires a newline before the binary comment line and that's what lopdf implements. However looking at what other pdf libraries implement they are a bit less strict,

- **pypdf**: Only validates the 5-byte `%PDF-` prefix, doesn't parse the rest of the line
- **pdf.js** (Mozilla): Reads version chars until whitespace (byte ≤ 0x20) or 7 chars max, ignores the rest
- **qpdf**: Uses regex `[0-9]+\.[0-9]+` to extract version digits, ignores trailing bytes

so I would propose to be a bit less strict about this header parsing, to not error on such files. There should be little downside.

Made a PR in https://github.com/J-F-Liu/lopdf/pull/481



Discussion:
pypdf can set a parameter: strict=False/True, lopdf should also support this.
Thanks for the feedback! I'll add it to the PR. 

Do you want the default to be strict=true or false? In pypdf it looks like they switched the default from true to false eventually. 
And also API wise @J-F-Liu if I try to add a strict flag in #481. Would you prefer,
```rs
  let doc = Document::load_with_options("file.pdf", &LoadOptions::lenient())?;
  let doc = Document::load_mem_with_options(&bytes, &LoadOptions::lenient())?;
```
or 
```rs
Document::load_lenient("f.pdf")?;
// load remains the strict version
```

or some other pattern?

> Do you want the default to be strict=true or false?

I imagine strict by default for backward compatibility?
I suggest keep `Document::load`, add `Document::load_with_options` to replace `load_with_password` and `load_filtered`.
Default to strict=false is ok.



Patch:
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 279d650..3c5664c 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -409,15 +409,28 @@ fn _indirect_object<'a>(
     Ok((object_id, object))
 }
 
-pub fn header(input: ParserInput) -> Option<String> {
-    strip_nom(map_res(
-        delimited(
-            tag(&b"%PDF-"[..]),
+pub fn header(input: ParserInput, strict: bool) -> Option<String> {
+    // Parse version digits (e.g. "1.7") separately from any trailing bytes
+    // before the newline.  Some PDF generators (e.g. ImageMill) place binary
+    // marker bytes on the header line which would fail UTF-8 validation.
+    // In strict mode we reject such trailing bytes; in lenient mode we skip them.
+    let (_, (version_raw, trailing)) = delimited(
+        tag(&b"%PDF-"[..]),
+        pair(
+            take_while(|c: u8| c.is_ascii_digit() || c == b'.'),
             take_while(|c: u8| !b"\r\n".contains(&c)),
-            pair(eol, many0_count(comment)),
         ),
-        |v: ParserInput| str::from_utf8(&v).map(Into::into),
-    ).parse(input))
+        pair(eol, many0_count(comment)),
+    )
+    .parse(input)
+    .ok()?;
+
+    if strict && !trailing.is_empty() {
+        return None;
+    }
+
+    let version = str::from_utf8(&version_raw).ok()?.to_string();
+    Some(version)
 }
 
 pub fn binary_mark(input: ParserInput) -> Option<Vec<u8>> {
@@ -802,6 +815,49 @@ startxref
         }
     }
 
+    #[test]
+    fn header_standard() {
+        // Standard header with proper EOL
+        let input = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n";
+        assert_eq!(header(test_span(input), false), Some("1.7".to_string()));
+    }
+
+    #[test]
+    fn header_with_binary_bytes_on_same_line() {
+        // Some generators (e.g. ImageMill) place binary marker bytes on the
+        // header line without a separating newline or '%' prefix.
+        let input = b"%PDF-1.3 \xb0\x9f\x92\x9c\x9f\xd4\xe0\xce\xd0\xd0\xd0\r1 0 obj\r";
+        assert_eq!(header(test_span(input), false), Some("1.3".to_string()));
+    }
+
+    #[test]
+    fn header_with_binary_bytes_strict_rejects() {
+        // In strict mode, binary bytes on the header line should cause a
+        // parse failure (the raw bytes are not valid UTF-8).
+        let input = b"%PDF-1.3 \xb0\x9f\x92\x9c\x9f\xd4\xe0\xce\xd0\xd0\xd0\r1 0 obj\r";
+        assert_eq!(header(test_span(input), true), None);
+    }
+
+    #[test]
+    fn header_cr_line_ending() {
+        // CR-only line ending (common in older PDFs)
+        let input = b"%PDF-1.3\r%\xe2\xe3\xcf\xd3\r";
+        assert_eq!(header(test_span(input), false), Some("1.3".to_string()));
+    }
+
+    #[test]
+    fn header_crlf_line_ending() {
+        // CRLF line ending (common on Windows-generated PDFs)
+        let input = b"%PDF-1.7\r\n%\xe2\xe3\xcf\xd3\r\n";
+        assert_eq!(header(test_span(input), false), Some("1.7".to_string()));
+    }
+
+    #[test]
+    fn header_pdf_2_0() {
+        let input = b"%PDF-2.0\n%\xe2\xe3\xcf\xd3\n";
+        assert_eq!(header(test_span(input), false), Some("2.0".to_string()));
+    }
+
     #[test]
     fn content_with_comments() {
         // It should be processed as usual but ignoring the comments
diff --git a/src/reader.rs b/src/reader.rs
index f834c7b..839c035 100644
--- a/src/reader.rs
+++ b/src/reader.rs
@@ -500,7 +500,7 @@ impl Reader<'_> {
         self.buffer = &self.buffer[offset..];
 
         let version =
-            parser::header(ParserInput::new_extra(self.buffer, "header")).ok_or(ParseError::InvalidFileHeader)?;
+            parser::header(ParserInput::new_extra(self.buffer, "header"), self.strict).ok_or(ParseError::InvalidFileHeader)?;
 
         let xref_start = Self::get_xref_start(self.buffer)?;
         if xref_start > self.buffer.len() {
@@ -741,7 +741,7 @@ impl Reader<'_> {
         // The document structure can be expressed in PEG as:
         //   document <- header indirect_object* xref trailer xref_start
         let version =
-            parser::header(ParserInput::new_extra(self.buffer, "header")).ok_or(ParseError::InvalidFileHeader)?;
+            parser::header(ParserInput::new_extra(self.buffer, "header"), self.strict).ok_or(ParseError::InvalidFileHeader)?;
 
         //The binary_mark is in line 2 after the pdf version. If at other line number, then will be declared as invalid pdf.
         if let Some(pos) = self.buffer.iter().position(|&byte| byte == b'\n') {


Test patch:
diff --git a/tests/load_options_test.rs b/tests/load_options_test.rs
index 6b491fd..181bd77 100644
--- a/tests/load_options_test.rs
+++ b/tests/load_options_test.rs
@@ -187,4 +187,36 @@ mod sync_tests {
         let result = Document::load_with_options("nonexistent.pdf", LoadOptions::default());
         assert!(result.is_err());
     }
+
+    #[test]
+    fn strict_rejects_binary_bytes_on_header_line() {
+        // Minimal PDF-like buffer with binary bytes on the header line.
+        // Strict mode should reject this with InvalidFileHeader.
+        let buf = b"%PDF-1.3 \xb0\x9f\x92\x9c\r%%EOF\r";
+        let result = Document::load_mem_with_options(
+            buf,
+            LoadOptions { strict: true, ..Default::default() },
+        );
+        let err = result.unwrap_err().to_string();
+        assert!(
+            err.contains("invalid file header"),
+            "expected InvalidFileHeader, got: {err}"
+        );
+    }
+
+    #[test]
+    fn lenient_accepts_binary_bytes_on_header_line() {
+        // Same buffer, but lenient (default) mode should parse the header
+        // successfully (it will fail later because the rest isn't a real PDF,
+        // but the header itself should be accepted).
+        let buf = b"%PDF-1.3 \xb0\x9f\x92\x9c\r%%EOF\r";
+        let result = Document::load_mem_with_options(buf, LoadOptions::default());
+        // The error should NOT be InvalidFileHeader — the header parsed fine.
+        if let Err(e) = &result {
+            assert!(
+                !e.to_string().contains("invalid file header"),
+                "lenient mode should accept binary bytes on header line, got: {e}"
+            );
+        }
+    }
 }