mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 02:29:31 -06:00
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:
co-authored by
Claude Sonnet 5.5
Brandon Thomas
parent
b955c2eefb
commit
cbbeb61650
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+15
-8
@@ -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")?;
|
||||
|
||||
Reference in New Issue
Block a user