mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
fix(xfa): bound the text the draw phase lays out, so large text in many instances stops
draw_value_text re-measures overflowing text up to six times to shrink it, and a repeating subform draws its text once per instance, with no shared cache. A small file with large text drawn across many instances therefore cost one full text layout per instance per pass, for seconds of work, and finished with a valid layout, so neither the page limit nor the measurement budget stopped it. Each pass over a draw's text now counts its characters against a new draw budget (MAX_DRAW_CHARS), the way the measurement budget bounds container sizing. A real form's draws stay far below it; a template that lays out far more text stops with TooLarge. The budget is reduced under test so the guard is exercised without laying out hundreds of megabytes. Test: large_text_drawn_in_many_instances_spends_the_draw_budget (fifty instances of a 20,000-character draw give TooLarge; one copy lays out); it fails without the cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
This commit is contained in:
1 parent
3936f93a14
commit
8a6aff26e0
4 files changed
+47
-3
No files matched your search
@@ -43,6 +43,15 @@ const MAX_DEPTH: usize = 64;
|
||||
/// Most size measurements of one layout. Measurements of containers are memoized, so a real
|
||||
/// form stays far below this; it bounds the work a hostile template can cause.
|
||||
const MAX_MEASURES: usize = 2_000_000;
|
||||
/// Most characters the draw phase lays out in one layout, each pass counted. A draw re-measures
|
||||
/// overflowing text up to six times to shrink it, and a repeating subform draws its text once
|
||||
/// per instance, so large text drawn many times would otherwise cost seconds; a real form's
|
||||
/// draws stay far below this. Reduced under test so the guard is exercised without laying out
|
||||
/// hundreds of megabytes of text.
|
||||
#[cfg(not(test))]
|
||||
const MAX_DRAW_CHARS: usize = 256 << 20;
|
||||
#[cfg(test)]
|
||||
const MAX_DRAW_CHARS: usize = 1 << 20;
|
||||
/// Most initial instances of one repeating subform.
|
||||
const MAX_INSTANCES: usize = 50;
|
||||
/// Largest picture taken from the data (decoded bytes), as for template pictures.
|
||||
@@ -149,6 +158,8 @@ struct Layouter<'t> {
|
||||
measured: RefCell<HashMap<MeasureKey, Measured>>,
|
||||
/// Measurements made so far (see [`MAX_MEASURES`]).
|
||||
measures: Cell<usize>,
|
||||
/// Characters the draw phase has laid out, each pass counted (see [`MAX_DRAW_CHARS`]).
|
||||
drawn_chars: Cell<usize>,
|
||||
/// The fields and draws of the block being placed: (first item, one past the last, top,
|
||||
/// bottom) on the last page. A page break keeps each whole (see `split_block`).
|
||||
leaves: Vec<(usize, usize, f64, f64)>,
|
||||
@@ -199,6 +210,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
image_bytes: 0,
|
||||
measured: RefCell::new(HashMap::new()),
|
||||
measures: Cell::new(0),
|
||||
drawn_chars: Cell::new(0),
|
||||
leaves: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -229,11 +229,19 @@ impl Layouter<'_> {
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let face = Face::of(font);
|
||||
// Each pass over the text — a re-measure that shrinks it, or the final layout — counts
|
||||
// its characters against the draw budget, so large text drawn in many instances stops.
|
||||
let cost = rich.plain().chars().count().max(1);
|
||||
// Text that doesn't fit the height it was given (the template's face is narrower than
|
||||
// the standard one drawn in its place, so it wraps to more lines) is drawn smaller,
|
||||
// down to 70%, rather than over what lies below.
|
||||
let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0;
|
||||
let fits = |k: f64| {
|
||||
self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost));
|
||||
text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0
|
||||
};
|
||||
let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) };
|
||||
self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost));
|
||||
self.check_drawn_chars()?;
|
||||
let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max));
|
||||
if scale < 1.0 {
|
||||
for s in &mut block.spans {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
use super::EMBED_COPIED;
|
||||
use super::items::{Item, Page, UNDERLINE_DROP};
|
||||
use super::{
|
||||
Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect,
|
||||
SomIndices, XfaError, text,
|
||||
Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para,
|
||||
Rc, Rect, SomIndices, XfaError, text,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
@@ -39,6 +39,14 @@ impl Layouter<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `TooLarge` once the draw phase has laid out far more text than a real form does.
|
||||
pub(super) fn check_drawn_chars(&self) -> Result<(), XfaError> {
|
||||
if self.drawn_chars.get() > MAX_DRAW_CHARS {
|
||||
return Err(XfaError::TooLarge("the form draws far more text than the reader lays out".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> {
|
||||
self.check_measures()?;
|
||||
self.items += 1;
|
||||
|
||||
@@ -678,6 +678,22 @@ fn empty_repeating_subforms_spend_the_layout_budget() {
|
||||
assert!(layout_xml(&tpl).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_text_drawn_in_many_instances_spends_the_draw_budget() {
|
||||
// A draw whose text overflows its fixed height is re-measured up to six times to shrink it,
|
||||
// and a repeating subform draws it once per instance. Large text drawn in many instances
|
||||
// used to cost one full text layout per instance per pass, for seconds of work on a small
|
||||
// file; each pass now counts its characters against the draw budget.
|
||||
let words = "word ".repeat(4000); // 20,000 characters
|
||||
let draw = format!("<draw w=\"1in\" h=\"0.5in\"><value><text>{words}</text></value></draw>");
|
||||
let many =
|
||||
format!("<template><subform layout=\"tb\"><subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>{draw}</subform></subform></template>");
|
||||
assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))), "large text in many instances must be refused");
|
||||
// One copy of the same draw lays out: the budget does not reject an ordinary form.
|
||||
let one = format!("<template><subform layout=\"tb\">{draw}</subform></template>");
|
||||
assert!(layout_xml(&one).is_ok(), "a single large draw must lay out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_stream_listed_many_times_is_read_once() {
|
||||
let mut doc = Document::open(Arc::new(shell("<template><subform/></template>"))).unwrap();
|
||||
|
||||
Reference in new issue
Block a user