fix(edit): bound the text one page's extraction can expand into

Text extraction copies a ToUnicode destination for every glyph showing its
code, and the page's total text was not bounded, so a small page could expand
into gigabytes. One page's extraction (its forms included) stops with an error
past 16 MiB of text.
This commit is contained in:
aiongg
2026-10-22 21:17:15 +00:00
parent c9090b00ef
commit fdcfe16b7d
2 changed files with 43 additions and 6 deletions
+22
View File
@@ -6402,3 +6402,25 @@ fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() {
let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len();
assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay");
}
/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text
/// would run past the extraction budget is refused, not built in memory.
#[test]
fn page_text_past_the_budget_is_refused() {
let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256)));
let page = |shown: &str| {
let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET"));
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
&content,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>",
&cmap,
])
};
let doc = page(&"A".repeat(100_000));
assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page");
let doc = page("AA");
assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads");
}
+21 -6
View File
@@ -887,8 +887,15 @@ struct Carry {
marked: Vec<Option<usize>>,
/// For each open marked-content sequence, its optional content group when it is `/OC`.
layers: Vec<Option<Vec<u8>>>,
/// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]).
text_left: usize,
}
/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is
/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes;
/// real pages hold far less (a whole novel is about 1 MB).
const MAX_PAGE_TEXT: usize = 16 << 20;
impl Carry {
fn new() -> Self {
let ts = Ts {
@@ -907,7 +914,7 @@ impl Carry {
vertical: None,
unreadable: None,
};
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
}
}
@@ -1119,7 +1126,13 @@ fn interpret(
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => text.push_str(&t),
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
@@ -1578,7 +1591,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
let mut forms = invoked.into_iter().peekable();
for s in shown {
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
enter(w, resources, &name, ts)?;
enter(w, resources, &name, ts, &mut carry.text_left)?;
// Text after a form starts a new line.
last = None;
}
@@ -1590,21 +1603,23 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
add_shown(&mut w.lines, &mut last, s, si);
}
for (_, name, ts) in forms {
enter(w, resources, &name, ts)?;
enter(w, resources, &name, ts, &mut carry.text_left)?;
}
Ok(())
}
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> {
/// `text_left`: the page's text budget, shared with the forms the page draws.
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts, text_left: &mut usize) -> Result<(), EditError> {
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() };
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
let stream = w.next_stream;
w.next_stream = w.next_stream.saturating_add(1);
if let Some(r) = form.obj {
w.path.push(r);
}
walk(w, &ops, &form.resources, &mut carry, &|_| stream)?;
*text_left = carry.text_left;
if form.obj.is_some() {
w.path.pop();
}