diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 8f656d7eaf..b65e72477a 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, @@ -75,6 +75,14 @@ impl Layouter<'_> { let mut seg_top = top; let mut first_segment = true; loop { + // Each pass rescans every item still to be placed. A block split across very many + // content areas, and so across very many pages, would do this once per page, costing + // time quadratic in its items; the still-unplaced count spends the work budget so a + // crafted template stops rather than run for seconds before the page limit catches it. + self.measures.set(self.measures.get().saturating_add(rest.len())); + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("a subform is split across too many content areas to lay out".into())); + } let (rect, _, placed) = self.cursor(); let limit = rect.bottom() + shift; let mut cut = limit; diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 76409dc697..a627c70e5f 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -21,6 +21,10 @@ const MAX_IMAGE: usize = 32 << 20; const MAX_ITEMS: usize = 1000; /// Longest text run kept. const MAX_TEXT: usize = 1 << 20; +/// Most content areas kept on one page area. A master page has a handful (the most in the +/// 500-form corpus is nine); laying a splittable subform out across tens of thousands of them, +/// which `next_area` walks before starting a new page, costs time quadratic in the paragraphs. +const MAX_CONTENT_AREAS: usize = 128; /// Parse a measurement such as `12.7mm`, `72pt`, `1in`, `2.54cm` into points. Bare numbers are /// points. `None` for anything else (including `em`). @@ -609,7 +613,19 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS attr_measure(c, "h").unwrap_or(height).clamp(1.0, height), ) }) + .take(MAX_CONTENT_AREAS + 1) .collect(); + if content.len() > MAX_CONTENT_AREAS { + content.truncate(MAX_CONTENT_AREAS); + // Said once however many warnings came before: other warnings fill the shared cap of + // 100 without limit of their own, and this one must not go silent behind them. It is + // kept first, so whether it was said is one comparison, not a scan of every warning + // for every page area (nothing else is put first). + let said = format!("a page area has more than {MAX_CONTENT_AREAS} content areas; the rest are left out"); + if warnings.first() != Some(&said) { + warnings.insert(0, said); + } + } if content.is_empty() { content.push(Rect::new(0.0, 0.0, width, height)); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 2e70a81472..b4c59df5d0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -188,6 +188,46 @@ fn the_template_parses_with_fonts_captions_and_rich_text() { assert!(last.break_before_page); } +/// A page area that declares far more content areas than any real master keeps only the first +/// 128, with a warning. `split_block` walks every content area of a page before starting a new +/// one, re-scanning the unplaced items each time, so tens of thousands of them cost time +/// quadratic in a splittable subform's paragraphs. Capping the count bounds that walk. +#[test] +fn a_page_area_keeps_at_most_a_hundred_and_twenty_eight_content_areas() { + let areas = (0..200).map(|i| format!("", i)).collect::(); + let xml = format!( + r##" + + + +"## + ); + let (tpl, warnings) = parse(&xml).unwrap(); + let ps = tpl.root.page_set.as_ref().unwrap(); + assert_eq!(ps.areas[0].content.len(), 128, "the content areas are capped"); + assert!(warnings.iter().any(|w| w.contains("content areas")), "a warning records the cap: {warnings:?}"); + // Still said after a hundred other warnings (CodeRabbit review on #5844): images that can't + // be decoded, parsed before the page set, fill the warnings' shared cap first. + let bad = "!!!".repeat(120); + let crowded = xml.replace("", &format!("{bad}")); + let (_, warnings) = parse(&crowded).unwrap(); + assert!(warnings.len() > 100, "the other warnings came first: {}", warnings.len()); + assert!(warnings.iter().any(|w| w.contains("content areas")), "the cap is still recorded"); + // Many capped page areas say it once, found without scanning every warning (it comes first). + let page_area = |n: usize| format!(r#"{areas}"#); + let many: String = (0..50).map(page_area).collect(); + let crowded = crowded.replacen(&page_area(0).replace("p0", "front"), &many, 1); + let (_, warnings) = parse(&crowded).unwrap(); + assert_eq!(warnings.iter().filter(|w| w.contains("content areas")).count(), 1, "said once"); + assert!(warnings[0].contains("content areas"), "kept first: {:?}", warnings.first()); +} + #[test] fn rich_text_max_length_reaches_the_field_and_layout() { for (attribute, expected) in [ @@ -678,6 +718,29 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_block_split_across_very_many_pages_spends_the_layout_budget() { + // One-point content areas slice a flowed subform's paragraphs one line to a page, so the + // block is split across hundreds of pages; split_block rescans the still-unplaced + // paragraphs once per page, which is quadratic in their number. Fewer paragraphs than this + // lay out (188 pages here), so the page limit does not catch it; the rescans spend the + // work budget instead. + let areas: String = (0..128).map(|i| format!("")).collect(); + let paras: String = (0..2500).map(|i| format!("

Paragraph number {i} with a few words to fill a line.

")).collect(); + let tpl = format!( + "" + ); + match layout_xml(&tpl) { + // It says what ran out, not that the form is nested too deeply (CodeRabbit review on #5844). + Err(XfaError::TooLarge(m)) => assert!(m.contains("split across"), "{m}"), + other => panic!("a block split across very many pages must be refused: {other:?}"), + } +} + #[test] fn a_packet_stream_listed_many_times_is_read_once() { let mut doc = Document::open(Arc::new(shell(""))).unwrap();