fix(parse): accept spaces between stream and its line break (#3378)

* fix(parse): accept spaces between stream and its line break

Microsoft Reporting Services writes `stream \r\n`, a space before the line break.
hayro-syntax and pdfcraft-cos took the stream data to start at the space, so the
content stream failed to inflate: the page rendered blank (white, or black with
inverted colours) and an edited save corrupted the stream. Spaces and tabs are now
skipped, but only when a line break follows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Number the stream end-of-line patch (20), as vendor/README does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Pedro Luiz Colognese
2026-10-19 03:50:21 -03:00
committed by GitHub
parent b955c2eefb
commit cbbeb61650
4 changed files with 72 additions and 10 deletions
+29 -1
View File
@@ -404,7 +404,16 @@ fn parse_indirect_in(
if let Object::Dict(dict) = &obj
&& lx.eat_keyword(b"stream")
{
// The keyword is followed by CRLF or LF (a lone CR is tolerated).
// The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting
// Services writes a space first (`stream \r\n`); spaces and tabs count only when a line
// break follows, so they are never taken from the data.
let mut eol = lx.pos;
while matches!(data.get(eol), Some(b' ' | b'\t')) {
eol += 1;
}
if matches!(data.get(eol), Some(b'\r' | b'\n')) {
lx.pos = eol;
}
match (data.get(lx.pos), data.get(lx.pos + 1)) {
(Some(b'\r'), Some(b'\n')) => lx.pos += 2,
(Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
@@ -554,6 +563,25 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
// line break, so the /Length is still right and the data keeps no leading space.
for eol in [&b" \r\n"[..], b" \n", b"\t \r"] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec();
data.extend_from_slice(eol);
data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n");
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{eol:?}");
}
// Spaces with no line break after them are data, not a keyword terminator.
let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b" HELLO");
}
#[test]
fn indirect_length_is_resolved() {
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
+27
View File
@@ -2881,6 +2881,33 @@ trailer << /Root 1 0 R >>
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
}
/// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space
/// before the line break. The content stream was then taken to start at the space and the
/// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax
/// patch: spaces and tabs before the line break are skipped, with the right `/Length` and
/// with a wrong one. Spaces with no line break after them never start stream data.
#[test]
fn space_before_the_line_break_after_stream_still_renders() {
let pdf = |eol: &str, length: u32| {
format!(
"%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\
4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\
trailer << /Root 1 0 R >>\r\n%%EOF"
)
.into_bytes()
};
for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] {
let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{eol:?}: {:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square");
assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white");
}
}
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
+1 -1
View File
File diff suppressed because one or more lines are too long
+15 -8
View File
@@ -353,14 +353,25 @@ impl FilterResult<'_> {
}
}
/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services
/// writes `stream \r\n`, a space before the line break, which the specification does not allow.
/// Without this the content stream was taken to start at the space, failed to inflate, and the
/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and
/// tabs are skipped, and only when a line break follows, so stream data is never swallowed.
fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> {
r.forward_while(|b| b == b' ' || b == b'\t');
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))
}
fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>> {
let length = dict.get::<u32>(LENGTH)?;
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let data = r.read_bytes(length as usize)?;
r.skip_white_spaces();
r.forward_tag(b"endstream")?;
@@ -376,11 +387,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>>
// A wrong `/Length` still recovers, because that keyword sits directly after the dict.
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let tail = r.tail()?;
let endstream_offset = find_needle(tail, b"endstream")?;