From f9d37c089f617b4ab52267645c615ca76db1daba Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 19 Oct 2026 17:33:48 +0800 Subject: [PATCH 1/3] fix(render): use unfiltered inline image byte length before EI heuristic (#3107) --- crates/render/tests/inline_image_ei.rs | 155 ++++++++++++++++ vendor/hayro-syntax/src/content/mod.rs | 239 ++++++++++++++++++++++++- 2 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 crates/render/tests/inline_image_ei.rs diff --git a/crates/render/tests/inline_image_ei.rs b/crates/render/tests/inline_image_ei.rs new file mode 100644 index 000000000..51fc6d296 --- /dev/null +++ b/crates/render/tests/inline_image_ei.rs @@ -0,0 +1,155 @@ +//! Regression tests for inline images whose unfiltered samples contain `"EI "` (#3107). + +use std::fmt::Write as _; +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +fn build_pdf(pages: &[Vec], extra_objects: &[(usize, Vec)], page_xobjects: &str) -> Vec { + let n = pages.len(); + let font = 3 + 2 * n; + let mut objs: Vec<(usize, Vec)> = Vec::new(); + objs.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec())); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push((2, format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" ")).into_bytes())); + for (i, content) in pages.iter().enumerate() { + let page_num = 3 + 2 * i; + let stream_num = 4 + 2 * i; + objs.push(( + page_num, + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 {font} 0 R >> {page_xobjects} >> >>") + .into_bytes(), + )); + let mut stm = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + stm.extend_from_slice(content); + stm.extend_from_slice(b"\nendstream"); + objs.push((stream_num, stm)); + } + objs.push((font, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec())); + for (id, body) in extra_objects { + objs.push((*id, body.clone())); + } + objs.sort_by_key(|(id, _)| *id); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::with_capacity(objs.len()); + for (id, body) in &objs { + offs.push(out.len()); + out.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref_offset = out.len(); + let size = objs.len() + 1; + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for off in offs { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + out +} + +fn page_stream(image: &[u8], n: usize) -> Vec { + let mut s = b"q 200 0 0 200 100 300 cm ".to_vec(); + s.extend_from_slice(image); + s.extend_from_slice(format!(" Q BT /F1 18 Tf 72 700 Td (After image P{n}) Tj ET").as_bytes()); + s +} + +fn mean_grey_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> f64 { + let mut sum = 0u64; + let mut count = 0u64; + for y in y0..y1 { + for x in x0..x1 { + let idx = ((y * width + x) * 4) as usize; + sum += u64::from(rgba[idx]); + count += 1; + } + } + sum as f64 / count.max(1) as f64 +} + +fn dark_pixels_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize { + let mut dark = 0usize; + for y in y0..y1 { + for x in x0..x1 { + let idx = ((y * width + x) * 4) as usize; + if rgba[idx] < 128 && rgba[idx + 1] < 128 && rgba[idx + 2] < 128 { + dark += 1; + } + } + } + dark +} + +#[test] +fn unfiltered_inline_image_samples_containing_ei_are_not_executed_as_content() { + let samples = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q"; + let w = samples.len(); + let mut hexed = String::with_capacity(w * 2 + 1); + for b in samples { + let _ = write!(hexed, "{b:02X}"); + } + hexed.push('>'); + + let mut img1 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID ").into_bytes(); + img1.extend_from_slice(samples); + img1.extend_from_slice(b" EI"); + + let mut img2 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID 0AB").into_bytes(); + img2.extend_from_slice(&samples[3..]); + img2.extend_from_slice(b" EI"); + + let img3 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /F /AHx ID {hexed} EI").into_bytes(); + let img4 = b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0 EI".to_vec(); + + let pdf = Arc::new(build_pdf(&[page_stream(&img1, 1), page_stream(&img2, 2), page_stream(&img3, 3), page_stream(&img4, 4)], &[], "")); + + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + let mut greys = Vec::new(); + for page_idx in 0..4 { + let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(text_page.error.is_none(), "page {} text error: {:?}", page_idx + 1, text_page.error); + let text = text_page.text.expect("page text"); + let plain = text.plain_text(); + assert_eq!(plain.trim(), format!("After image P{}", page_idx + 1), "page {}", page_idx + 1); + assert!(text.find("Injected").is_empty(), "page {} must not match Injected in Find", page_idx + 1); + + let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "page {} render error: {:?}", page_idx + 1, rendered.error); + + let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196); + assert_eq!(dark, 0, "page {} must not draw Injected at (72, 600)", page_idx + 1); + + let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482); + greys.push(grey); + } + + // Page 1 and Page 3 have the exact same 47 samples (unfiltered vs ASCIIHexDecode). + assert!((greys[0] - greys[2]).abs() < 1.0, "page 1 mean grey ({}) must match page 3 ({})", greys[0], greys[2]); + assert!((greys[0] - greys[1]).abs() < 2.0, "page 1 mean grey ({}) must match control page 2 ({})", greys[0], greys[1]); + assert!((greys[3] - 128.0).abs() < 2.0, "page 4 control mean grey ({})", greys[3]); +} + +#[test] +fn form_xobject_inline_image_with_ei_in_samples_keeps_all_samples() { + let form_content = b"q 200 0 0 200 100 300 cm BI /W 12 /H 1 /BPC 8 /CS /G ID 0EI 1 0 0 rg EI Q BT /F1 18 Tf 72 700 Td (Form after) Tj ET"; + let mut form_obj = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ) + .into_bytes(); + form_obj.extend_from_slice(form_content); + form_obj.extend_from_slice(b"\nendstream"); + + let page_content = b"/Fm1 Do".to_vec(); + // 1 page => font is object 5, form XObject is object 6. + let pdf = Arc::new(build_pdf(&[page_content], &[(6, form_obj)], "/XObject << /Fm1 6 0 R >>")); + + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + let rendered = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + + let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482); + assert!((grey - 54.37).abs() < 1.0, "form inline image mean grey {grey} must be ~54.37 (not ~2 from 1-byte cut)"); +} diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 8e47324d0..9fe603e4f 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -35,12 +35,12 @@ pub mod ops; use crate::content::ops::TypedInstruction; use crate::object; -use crate::object::dict::InlineImageDict; +use crate::object::dict::{Dict, InlineImageDict}; use crate::object::name::{Name, skip_name_like}; use crate::object::{Array, Null, Number, Object, Stream}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; -use crate::trivia::{Comment, is_white_space_character}; +use crate::trivia::{Comment, is_regular_character, is_white_space_character}; use crate::util::find_needle; use core::array; use core::fmt::{Debug, Formatter}; @@ -242,7 +242,28 @@ impl<'a> UntypedIter<'a> { let mut lookahead_touched = false; let mut accepted = false; - 'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") { + // PdfCraft patch: an unfiltered inline image's byte length is determined by + // its parameters (and `/L` or `/Length` when present and consistent), so + // `EI` inside its samples does not cut the image short (#3107). + match unfiltered_image_end(stream_data, &dict, self.complete) { + UnfilteredEnd::Found(end_data, end_after_ei) => { + let image_data = stream_data.get(..end_data)?; + let stream = Stream::new(image_data, dict.clone()); + self.stack.push(Object::Stream(stream))?; + self.reader.read_bytes(end_after_ei)?; + self.reader.skip_white_spaces(); + accepted = true; + } + UnfilteredEnd::NeedsMore => { + self.needs_more = true; + return None; + } + UnfilteredEnd::Fallback => {} + } + + 'outer: while !accepted + && let Some(pos) = find_needle(self.reader.tail()?, b"EI") + { self.reader.read_bytes(pos)?; if self.reader.peek_bytes(2) == Some(b"EI") { @@ -852,6 +873,166 @@ mod macros { pub(crate) use op6; } +/// PdfCraft patch: outcome of checking an unfiltered inline image's deterministic byte length. +enum UnfilteredEnd { + /// Deterministic end found: `(end_data, end_after_ei)`. + Found(usize, usize), + /// Window ran out before the terminator after the expected sample bytes could be decided. + NeedsMore, + /// Not an unfiltered image with a valid terminator at its computed length; use the heuristic. + Fallback, +} + +/// PdfCraft patch: an unfiltered inline image's sample byte count, including byte padding per row, +/// and `/Length` or `/L` when present and consistent (#3107). +fn unfiltered_image_len(dict: &Dict<'_>) -> Option { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get::>(key) + && !matches!(filter, Object::Null(_)) + && !matches!(&filter, Object::Array(filters) if filters.iter::>().next().is_none()) + { + return None; + } + } + for (full, short) in [ + (b"Width".as_slice(), b"W".as_slice()), + (b"Height", b"H"), + (b"ColorSpace", b"CS"), + (b"BitsPerComponent", b"BPC"), + (b"ImageMask", b"IM"), + (b"Length", b"L"), + ] { + if let (Some(full), Some(short)) = (dict.get::>(full), dict.get::>(short)) + && full != short + { + return None; + } + } + let get = |full: &[u8], short: &[u8]| { + dict.get::>(full) + .or_else(|| dict.get::>(short)) + }; + let int = |obj: &Object<'_>| match obj { + Object::Number(n) => i32::try_from(n.as_i64()) + .ok() + .filter(|&i| Number::from_i32(i) == *n) + .map(i64::from), + _ => None, + }; + let width = usize::try_from(int(&get(b"Width", b"W")?)?) + .ok() + .filter(|n| *n > 0)?; + let height = usize::try_from(int(&get(b"Height", b"H")?)?) + .ok() + .filter(|n| *n > 0)?; + let mask = match get(b"ImageMask", b"IM") { + None | Some(Object::Null(_) | Object::Boolean(false)) => false, + Some(Object::Boolean(true)) => true, + _ => return None, + }; + let bits = match get(b"BitsPerComponent", b"BPC") { + None | Some(Object::Null(_)) => None, + Some(value) => Some(int(&value)?), + }; + let (components, bits) = if mask { + if bits.is_some_and(|bits| bits != 1) { + return None; + } + (1usize, 1usize) + } else { + let (components, max_bits) = match get(b"ColorSpace", b"CS")? { + Object::Name(name) => match name.as_ref() { + b"DeviceGray" | b"G" => (1, 16), + b"DeviceRGB" | b"RGB" => (3, 16), + b"DeviceCMYK" | b"CMYK" => (4, 16), + _ => return None, + }, + Object::Array(space) => { + let mut items = space.iter::>(); + if !matches!(items.next(), Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I")) + || items.count() != 3 + { + return None; + } + (1, 8) + } + _ => return None, + }; + let bits = match bits? { + bits @ (1 | 2 | 4 | 8 | 16) => bits as usize, + _ => return None, + }; + if bits > max_bits { + return None; + } + (components, bits) + }; + let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); + let len = row.checked_mul(height)?; + match get(b"Length", b"L") { + None | Some(Object::Null(_)) => {} + Some(value) if usize::try_from(int(&value)?).ok() == Some(len) => {} + Some(_) => return None, + } + Some(len) +} + +/// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107). +fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool) -> UnfilteredEnd { + let Some(end_data) = unfiltered_image_len(dict) else { + return UnfilteredEnd::Fallback; + }; + let Some(after) = data.get(end_data..) else { + return if complete { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + }; + let Some(&first) = after.first() else { + return if complete { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + }; + if !is_white_space_character(first) { + return UnfilteredEnd::Fallback; + } + let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else { + return if complete { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + }; + let Some(rest) = after.get(separator..) else { + return UnfilteredEnd::Fallback; + }; + if rest.first() != Some(&b'E') { + return UnfilteredEnd::Fallback; + } + let Some(&second) = rest.get(1) else { + return if complete { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + }; + if second != b'I' { + return UnfilteredEnd::Fallback; + } + let Some(end_after_ei) = end_data.checked_add(separator).and_then(|ei| ei.checked_add(2)) else { + return UnfilteredEnd::Fallback; + }; + match rest.get(2) { + Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei), + Some(_) => UnfilteredEnd::Fallback, + None if complete => UnfilteredEnd::Found(end_data, end_after_ei), + None => UnfilteredEnd::NeedsMore, + } +} + #[cfg(test)] mod tests { use super::{ContentTokenizer, UntypedIter}; @@ -1014,4 +1195,56 @@ mod tests { assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data)); } } + + #[test] + fn unfiltered_inline_image_with_ei_in_samples_is_not_cut_early() { + // 48-byte unfiltered DeviceGray image whose first four bytes are `0EI ` + // followed by valid page content operators (`Q BT ... Tj ET q`). + let data = b"q 100 0 0 100 72 650 cm \ +BI /W 48 /H 1 /CS /G /BPC 8 ID 0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q! EI Q \ +BT /F1 14 Tf 72 550 Td (After) Tj ET"; + assert_eq!( + operators(data), + vec![ + b"q".to_vec(), + b"cm".to_vec(), + b"BI".to_vec(), + b"Q".to_vec(), + b"BT".to_vec(), + b"Tf".to_vec(), + b"Td".to_vec(), + b"Tj".to_vec(), + b"ET".to_vec(), + ] + ); + let expected = slice_instructions(data); + for piece in 1..=data.len() { + assert_eq!(windowed_instructions(data, || piece), expected, "piece size {piece}"); + } + } + + #[test] + fn unfiltered_inline_image_variants_and_fallbacks() { + // ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes) + let mask_stream = b"BI /IM true /W 10 /H 2 ID 0EI\n EI Q"; + assert_eq!(operators(mask_stream), vec![b"BI".to_vec(), b"Q".to_vec()]); + let expected_mask = slice_instructions(mask_stream); + for piece in 1..=mask_stream.len() { + assert_eq!(windowed_instructions(mask_stream, || piece), expected_mask); + } + + // Indexed color space with 4-bit samples (width 5 -> 3 bytes per row, height 2 -> 6 bytes) + // and explicit /L matching computed length. + let indexed_stream = + b"BI /Width 5 /Height 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 6 ID 0EI Q! EI Q"; + assert_eq!(operators(indexed_stream), vec![b"BI".to_vec(), b"Q".to_vec()]); + let expected_indexed = slice_instructions(indexed_stream); + for piece in 1..=indexed_stream.len() { + assert_eq!(windowed_instructions(indexed_stream, || piece), expected_indexed); + } + + // Conflicting /W and /Width or mismatched /L falls back to the heuristic scan. + let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q"; + assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]); + } } From 4ff9bd6634585060a5424471423b39337d3abcad Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 20 Oct 2026 11:15:45 +0800 Subject: [PATCH 2/3] fix(render): cover all color spaces, padding and align /L policy with content crate (#3107) --- crates/render/tests/inline_image_ei.rs | 42 ++++++++++++++++++++++++++ vendor/hayro-syntax/src/content/mod.rs | 37 +++++++++++++++++------ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/render/tests/inline_image_ei.rs b/crates/render/tests/inline_image_ei.rs index 51fc6d296..0cc4b1981 100644 --- a/crates/render/tests/inline_image_ei.rs +++ b/crates/render/tests/inline_image_ei.rs @@ -153,3 +153,45 @@ fn form_xobject_inline_image_with_ei_in_samples_keeps_all_samples() { let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482); assert!((grey - 54.37).abs() < 1.0, "form inline image mean grey {grey} must be ~54.37 (not ~2 from 1-byte cut)"); } + +#[test] +fn unfiltered_inline_image_layouts_and_mismatched_l_do_not_inject_content() { + let inject_prefix = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q"; + let make_image = |params: &str, len: usize| { + let mut samples = inject_prefix.to_vec(); + samples.resize(len, b'0'); + let mut img = format!("BI {params} ID ").into_bytes(); + img.extend_from_slice(&samples); + img.extend_from_slice(b" EI"); + img + }; + + let cases = [ + ("/W 16 /H 2 /CS /RGB /BPC 8", 96), + ("/W 12 /H 2 /CS /CMYK /BPC 8", 96), + ("/W 70 /H 4 /CS /G /BPC 1", 36), + ("/IM true /W 70 /H 4", 36), + ("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47), + ]; + + let pages: Vec> = cases.iter().enumerate().map(|(i, (params, len))| page_stream(&make_image(params, *len), i + 1)).collect(); + + let pdf = Arc::new(build_pdf(&pages, &[], "")); + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + + for (i, (params, _)) in cases.iter().enumerate() { + let page_idx = i; + let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(text_page.error.is_none(), "case {params} text error: {:?}", text_page.error); + let text = text_page.text.expect("page text"); + let plain = text.plain_text(); + assert_eq!(plain.trim(), format!("After image P{}", i + 1), "case {params}"); + assert!(text.find("Injected").is_empty(), "case {params} must not match Injected in Find"); + + let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "case {params} render error: {:?}", rendered.error); + + let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196); + assert_eq!(dark, 0, "case {params} must not draw Injected at (72, 600)"); + } +} diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 9fe603e4f..a18e91817 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -900,7 +900,6 @@ fn unfiltered_image_len(dict: &Dict<'_>) -> Option { (b"ColorSpace", b"CS"), (b"BitsPerComponent", b"BPC"), (b"ImageMask", b"IM"), - (b"Length", b"L"), ] { if let (Some(full), Some(short)) = (dict.get::>(full), dict.get::>(short)) && full != short @@ -968,13 +967,7 @@ fn unfiltered_image_len(dict: &Dict<'_>) -> Option { (components, bits) }; let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); - let len = row.checked_mul(height)?; - match get(b"Length", b"L") { - None | Some(Object::Null(_)) => {} - Some(value) if usize::try_from(int(&value)?).ok() == Some(len) => {} - Some(_) => return None, - } - Some(len) + row.checked_mul(height) } /// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107). @@ -1243,8 +1236,34 @@ BT /F1 14 Tf 72 550 Td (After) Tj ET"; assert_eq!(windowed_instructions(indexed_stream, || piece), expected_indexed); } - // Conflicting /W and /Width or mismatched /L falls back to the heuristic scan. + // Conflicting /W and /Width falls back to the heuristic scan; mismatched /L does not disrupt the verified layout. let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q"; assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]); + let conflict_w = b"BI /W 4 /Width 8 /H 1 /CS /G /BPC 8 ID abcd EI Q"; + assert_eq!(operators(conflict_w), vec![b"BI".to_vec(), b"Q".to_vec()]); + } + + #[test] + fn each_layout_keeps_its_computed_length() { + let inject = b" EI Q BT /F1 9 Tf (X) Tj ET q"; + let case = |params: &str, len: usize| { + let mut samples = inject.to_vec(); + samples.resize(len, b'0'); + let mut data = format!("BI {params} ID ").into_bytes(); + data.extend_from_slice(&samples); + data.extend_from_slice(b" EI Q"); + data + }; + for (params, len) in [ + ("/W 16 /H 2 /CS /RGB /BPC 8", 96), + ("/W 12 /H 2 /CS /CMYK /BPC 8", 96), + ("/W 70 /H 4 /CS /G /BPC 1", 36), + ("/IM true /W 70 /H 4", 36), + ("/W 30 /H 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 30", 30), + ("/W 15 /H 2 /CS /G /BPC 16 /L 60", 60), + ("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47), + ] { + assert_eq!(operators(&case(params, len)), vec![b"BI".to_vec(), b"Q".to_vec()], "{params}"); + } } } From 01af6cba3f4733303c626dc7fcedd67315891f2e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 22 Oct 2026 07:35:54 +0800 Subject: [PATCH 3/3] fix(syntax): bound inline-image lookahead by content window Fall back only when missing sample or terminator bytes cannot fit the content-window budget, including retained header and separator overhead. Preserve available deterministic boundaries and ordinary large streams. Add oversized, exact-budget, overflow and large-stream regressions. --- ROADMAP.md | 2 + docs/roadmap.md | 2 +- vendor/hayro-syntax/src/content/mod.rs | 109 +++++++++++++++++++------ 3 files changed, 89 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..1db693573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9..757162983 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index a18e91817..4dfc9eb6b 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -245,7 +245,12 @@ impl<'a> UntypedIter<'a> { // PdfCraft patch: an unfiltered inline image's byte length is determined by // its parameters (and `/L` or `/Length` when present and consistent), so // `EI` inside its samples does not cut the image short (#3107). - match unfiltered_image_end(stream_data, &dict, self.complete) { + match unfiltered_image_end( + stream_data, + &dict, + self.complete, + start_offset.saturating_sub(self.committed), + ) { UnfilteredEnd::Found(end_data, end_after_ei) => { let image_data = stream_data.get(..end_data)?; let stream = Stream::new(image_data, dict.clone()); @@ -971,33 +976,37 @@ fn unfiltered_image_len(dict: &Dict<'_>) -> Option { } /// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107). -fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool) -> UnfilteredEnd { +fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool, header_bytes: usize) -> UnfilteredEnd { let Some(end_data) = unfiltered_image_len(dict) else { return UnfilteredEnd::Fallback; }; + // An inline image shares the content window's budget, not the larger decoded-image + // budget. Include its dictionary/header and the shortest whitespace + EI + delimiter + // look-ahead, or one more byte beyond any separator whitespace already present. + // Missing bytes that cannot fit must use the existing heuristic, not pin the window + // and discard following content. Already-available exact ends still win. + let missing_end = if complete + || end_data + .checked_add(4) + .and_then(|minimum| data.len().checked_add(1).map(|next| minimum.max(next))) + .and_then(|needed| needed.checked_add(header_bytes)) + .is_none_or(|needed| needed > crate::MAX_DECODED_STREAM) + { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; let Some(after) = data.get(end_data..) else { - return if complete { - UnfilteredEnd::Fallback - } else { - UnfilteredEnd::NeedsMore - }; + return missing_end; }; let Some(&first) = after.first() else { - return if complete { - UnfilteredEnd::Fallback - } else { - UnfilteredEnd::NeedsMore - }; + return missing_end; }; if !is_white_space_character(first) { return UnfilteredEnd::Fallback; } let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else { - return if complete { - UnfilteredEnd::Fallback - } else { - UnfilteredEnd::NeedsMore - }; + return missing_end; }; let Some(rest) = after.get(separator..) else { return UnfilteredEnd::Fallback; @@ -1006,11 +1015,7 @@ fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool) -> Unfilte return UnfilteredEnd::Fallback; } let Some(&second) = rest.get(1) else { - return if complete { - UnfilteredEnd::Fallback - } else { - UnfilteredEnd::NeedsMore - }; + return missing_end; }; if second != b'I' { return UnfilteredEnd::Fallback; @@ -1022,13 +1027,14 @@ fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool) -> Unfilte Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei), Some(_) => UnfilteredEnd::Fallback, None if complete => UnfilteredEnd::Found(end_data, end_after_ei), - None => UnfilteredEnd::NeedsMore, + None => missing_end, } } #[cfg(test)] mod tests { use super::{ContentTokenizer, UntypedIter}; + use crate::reader::ReaderExt; fn operators(data: &[u8]) -> Vec> { let mut tokenizer = UntypedIter::new(data); @@ -1216,6 +1222,63 @@ BT /F1 14 Tf 72 550 Td (After) Tj ET"; } } + #[test] + fn oversized_unfiltered_inline_image_does_not_hold_following_content() { + for length in [crate::MAX_DECODED_STREAM - 1, crate::MAX_DECODED_STREAM, crate::MAX_DECODED_STREAM + 1] { + // The next BI supplies the existing EI heuristic's bounded look-ahead. The + // stream is not exhausted: an impossible declaration must not pin this window. + let data = format!( + "BI /W {length} /H 1 /CS /G /BPC 8 ID x EI BT (After) Tj ET \ + BI /W 1 /H 1 /CS /G /BPC 8 ID y EI Q" + ); + let mut tokenizer = UntypedIter::new_window(data.as_bytes(), false); + for expected in [b"BI".as_slice(), b"BT", b"Tj", b"ET"] { + let instruction = tokenizer.next_instruction().expect("following content survives"); + assert_eq!(instruction.operator.as_ref(), expected, "declared length {length}"); + } + } + } + + #[test] + fn unfiltered_inline_image_budget_includes_header_and_terminator() { + let mut reader = crate::reader::Reader::new(b"/W 1 /H 1 /CS /G /BPC 8 ID "); + let inline = reader + .read_without_context::>() + .expect("synthetic inline dictionary"); + let dict = inline.get_dict(); + let fits = crate::MAX_DECODED_STREAM - 5; // One sample + whitespace/EI/delimiter. + assert!(matches!(super::unfiltered_image_end(b"", dict, false, fits), super::UnfilteredEnd::NeedsMore)); + assert!(matches!(super::unfiltered_image_end(b"x ", dict, false, fits), super::UnfilteredEnd::Fallback)); + assert!(matches!(super::unfiltered_image_end(b"x EI", dict, false, fits + 1), super::UnfilteredEnd::Fallback)); + assert!(matches!(super::unfiltered_image_end(b"x EI", dict, true, usize::MAX), super::UnfilteredEnd::Found(1, 4))); + for header in [fits + 1, usize::MAX] { + assert!(matches!(super::unfiltered_image_end(b"", dict, false, header), super::UnfilteredEnd::Fallback)); + // The guard is only for missing bytes, never a reason to reinterpret available samples. + assert!(matches!(super::unfiltered_image_end(b"x EI Q", dict, false, header), super::UnfilteredEnd::Found(1, 4))); + } + } + + #[test] + fn large_unfiltered_inline_image_still_waits_for_its_samples() { + // A valid image larger than the interpreter's initial 64 KiB window must + // still use its exact length rather than execute operators in its samples. + let length = (64 << 10) + 1; + let mut data = format!("BI /W {length} /H 1 /CS /G /BPC 8 ID ").into_bytes(); + let sample_start = data.len(); + data.extend_from_slice(b"0EI Q BT (Injected) Tj ET q"); + data.resize(sample_start + length, b'0'); + data.extend_from_slice(b" EI BT (After) Tj ET"); + let mut partial = UntypedIter::new_window(&data[..64 << 10], false); + assert!(partial.next_instruction().is_none()); + assert!(partial.needs_more); + assert_eq!(partial.committed, 0); + assert_eq!(operators(&data), [b"BI".to_vec(), b"BT".to_vec(), b"Tj".to_vec(), b"ET".to_vec()]); + let expected = slice_instructions(&data); + for piece in [4096, 64 << 10, data.len()] { + assert_eq!(windowed_instructions(&data, || piece), expected, "piece size {piece}"); + } + } + #[test] fn unfiltered_inline_image_variants_and_fallbacks() { // ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes)