diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index d1d50e4502..7d745c0c10 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let crop = p.crop(doc); let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let streams = crate::text::content_streams(doc, &p.dict); + let streams = crate::text::content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 75bb12f47d..0f75ff3d3c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -73,23 +73,16 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + crate::text::content_streams(doc, page) +} + +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -237,7 +230,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -285,6 +278,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -358,6 +354,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -382,7 +381,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -483,7 +482,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -531,7 +530,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -755,7 +754,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237b..61e5e958ed 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,78 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..5784b95f3b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -615,7 +615,10 @@ struct Ts { unreadable: Option>, } -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -623,15 +626,20 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()], }, }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() + let mut budget = DecodeBudget::page(); + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) } /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators @@ -1284,7 +1292,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut carry = Carry::new(); // The streams are one stream in pieces (ยง7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; @@ -1490,10 +1498,11 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. -#[derive(Default)] +/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left +/// a form unread. pub(crate) struct FormBudget { visits: usize, + decode: DecodeBudget, /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. pub(crate) truncated: bool, @@ -1501,6 +1510,12 @@ pub(crate) struct FormBudget { /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. +impl Default for FormBudget { + fn default() -> Self { + Self { visits: 0, decode: DecodeBudget::page(), truncated: false } + } +} + pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1519,7 +1534,7 @@ pub(crate) fn form_call( path: &[pdfcraft_cos::ObjRef], budget: &mut FormBudget, ) -> Option { - let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; // Once a limit has cut the page short, refusing the rest costs nothing more. if limited && budget.truncated { return None; @@ -1546,13 +1561,16 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -1612,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing @@ -2235,7 +2253,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; @@ -2440,7 +2458,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; @@ -2661,7 +2679,7 @@ fn rewrite_block_from( { return rewrite_styled_block(doc, page, &b, &lines, text, style); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // Explicit line breaks stay (#1443); within a line, runs of white space become one space. @@ -3044,7 +3062,7 @@ pub(crate) fn selectable_blocks( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; Ok(group_blocks(&lines) .into_iter() @@ -3133,7 +3151,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Ok(()); } let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let mut selected = std::collections::BTreeSet::new(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 928280d5d8..a775594944 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block( row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and