mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
fix(edit): page content, forms and image records share per-page limits
The editor's page readers decoded every /Contents entry and every form entry with a fresh 1 GiB allowance, and the reading-order image inventory added a record per image draw in every form entered, so a few shared forms drawing each other produced millions of records. Content streams and form entries are decoded against one per-page DecodeBudget (content past it is an error, so no edit rewrites a page from part of its content; forms past it are reported truncated), and an inventory past 100,000 image draws is refused.
This commit is contained in:
@@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, 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::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
|
||||
|
||||
+21
-22
@@ -73,23 +73,16 @@ struct ClipPath {
|
||||
even_odd: bool,
|
||||
}
|
||||
|
||||
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
let list: Vec<Object> = 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<Vec<(Object, Vec<u8>)>, 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<Vec<PageImage>, 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<Vec<PageImage>, 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<Vec<PageImage>, 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<Vec<PageImage>, 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<ObjRef>,
|
||||
) -> Result<Vec<(usize, PageImage)>, 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()))?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+45
-27
@@ -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<Rc<str>>,
|
||||
}
|
||||
|
||||
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
/// 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<Vec<(Object, Vec<u8>)>, EditError> {
|
||||
let list: Vec<Object> = 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<u
|
||||
_ => 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<Vec<TextLine>, 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::<Vec<_>>());
|
||||
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
|
||||
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<pdfcraft_cos::ObjRef>,
|
||||
pub(crate) data: Vec<u8>,
|
||||
@@ -1519,7 +1534,7 @@ pub(crate) fn form_call(
|
||||
path: &[pdfcraft_cos::ObjRef],
|
||||
budget: &mut FormBudget,
|
||||
) -> Option<FormCall> {
|
||||
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<TextLine>, 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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
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<pdfcraft_cos::ObjRef>,
|
||||
) -> Result<Vec<(usize, TextBlock)>, 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();
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
|
||||
Reference in New Issue
Block a user