diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 6857c2a17..ee14e81f5 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -23,10 +23,19 @@ const PT_PER_MM: f64 = 72.0 / 25.4; const THINNEST: f64 = 0.25; /// The most objects an import makes. const MAX_NODES: usize = 2_000_000; -/// The most block visits (one per insert copy) an import makes, whatever they produce. -const MAX_VISITS: usize = 10_000_000; +/// The most block work an import does, whatever it produces: one per insert copy drawn, and one +/// per item of the block looked at for it (art on hidden layers too). +const MAX_VISITS: usize = if cfg!(test) { 1_000_000 } else { 20_000_000 }; /// The most path elements an import makes (blocks drawn as art are copied for every placement). const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 }; +/// The most dash lengths an import keeps (every dashed path holds its own copy of its pattern). +const MAX_DASH_VALUES: usize = if cfg!(test) { 1_000 } else { 4_000_000 }; +/// The most bytes of text, font names and symbol names an import copies into objects (a block's +/// text and name are copied for every placement). +const MAX_TEXT_BYTES: usize = if cfg!(test) { 64 << 10 } else { 64 << 20 }; +/// The longest font family a text style may name; a longer one is not a font, and is set in the +/// default. +const MAX_FAMILY_BYTES: usize = 256; /// How far from the origin art may lie (points): the canvas. pub(crate) const MAX_EXTENT: f64 = 4.0e6; @@ -34,6 +43,8 @@ const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are const XREFS: &str = "blocks referencing other drawings (external references) are left out"; const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out"; +const TOO_MANY_DASHES: &str = "the drawing has more dashed lines than can be kept: the rest are drawn solid"; +const TOO_MUCH_TEXT: &str = "the drawing has more than 64 MB of text and block names: the rest are left out"; /// What a block's art takes from the insert that places it: its colour, lineweight, linetype /// and opacity where the art says "by block", and its layer for art on layer 0. @@ -71,6 +82,9 @@ pub(crate) struct Build<'d, 'a> { visits: usize, /// Path elements made so far. elements: usize, + /// Dash lengths and text bytes kept so far. + dash_values: usize, + text_bytes: usize, warnings: Vec, } @@ -128,6 +142,8 @@ impl<'d, 'a> Build<'d, 'a> { nodes: 0, visits: 0, elements: 0, + dash_values: 0, + text_bytes: 0, warnings: vec![], } } @@ -138,6 +154,20 @@ impl<'d, 'a> Build<'d, 'a> { } } + /// Take `bytes` of text from [`MAX_TEXT_BYTES`]; false (with a warning) when they don't fit. + fn charge_text(&mut self, bytes: usize) -> bool { + match self.text_bytes.checked_add(bytes).filter(|t| *t <= MAX_TEXT_BYTES) { + Some(t) => { + self.text_bytes = t; + true + } + None => { + self.warn(TOO_MUCH_TEXT); + false + } + } + } + /// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own /// unit and at the scale asked for (fitting computes its own). pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec), String> { @@ -484,9 +514,15 @@ impl<'d, 'a> Build<'d, 'a> { if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut() && let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale) { - s.dash = Some(dash); - if dots { - s.cap = LineCap::Round; + match self.dash_values.checked_add(dash.pattern.len()).filter(|n| *n <= MAX_DASH_VALUES) { + Some(n) => { + self.dash_values = n; + s.dash = Some(dash); + if dots { + s.cap = LineCap::Round; + } + } + None => self.warn(TOO_MANY_DASHES), } } ap @@ -527,10 +563,15 @@ impl<'d, 'a> Build<'d, 'a> { if !(s > 1e-12 && s.is_finite()) { return None; } - let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned()); + let d = self.d; + let family = t.family.as_deref().or_else(|| d.styles.get(&t.style).map(String::as_str).filter(|f| !f.is_empty())); + let family = family.filter(|f| f.len() <= MAX_FAMILY_BYTES); + if !self.charge_text(t.text.len().saturating_add(family.map_or(0, str::len))) { + return None; + } let default = CharStyle::default(); let style = CharStyle { - font_family: family.unwrap_or(default.font_family.clone()), + font_family: family.map_or_else(|| default.font_family.clone(), str::to_string), size: t.height / CAP_HEIGHT * s, h_scale: t.width * 100.0, leading: t.leading.map(|l| l * s), @@ -558,6 +599,13 @@ impl<'d, 'a> Build<'d, 'a> { for cell in &ins.cells { let node = match &symbol { Some(name) => { + if self.nodes >= MAX_NODES { + self.warn(TOO_MANY); + break; + } + if !self.charge_text(name.len()) { + break; + } let xf = map * *cell * self.symbol_map.inverse(); self.nodes += 1; Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf })) @@ -581,14 +629,19 @@ impl<'d, 'a> Build<'d, 'a> { /// The art of block `name` drawn by `map`, as one object. fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { - self.visits += 1; - if self.visits > MAX_VISITS { - if self.visits == MAX_VISITS + 1 { - self.warn(TOO_MANY); - } + if self.visits >= MAX_VISITS { + self.warn(TOO_MANY); return None; } - let items = self.block(name)?; + let items = self.block(name); + // Each placement looks at every item of the block, drawn or not (art on a hidden layer + // makes nothing, so the node budget doesn't bound it). + self.visits = self.visits.saturating_add(1 + items.as_ref().map_or(0, |i| i.len())); + if self.visits > MAX_VISITS { + self.warn(TOO_MANY); + return None; + } + let items = items?; if !self.enter(name, depth) { return None; } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 6bd3d1c10..4ecba52f5 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -1177,3 +1177,100 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}"); assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings); } + +#[test] +fn dashed_lines_past_the_dash_budget_are_drawn_solid() { + // 600 lines of a two-length linetype: 1,200 dash lengths, past the 1,000 tests import with. + let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")]; + let line = [(0, "LINE"), (6, "DASHED"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]; + let e: Vec<(i32, &str)> = (0..600).flat_map(|_| line).collect(); + let r = open(&drawing(&[], &tables, &[], &e), &points()); + let (mut dashed, mut solid, mut kept) = (0, 0, 0); + r.document.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + match n.appearance.stroke().and_then(|s| s.dash.as_ref()) { + Some(d) => { + dashed += 1; + kept += d.pattern.len(); + } + None => solid += 1, + } + } + }); + assert_eq!(dashed + solid, 600); + assert!(dashed > 0 && solid > 0, "{dashed} dashed, {solid} solid"); + assert!(kept <= 1_000, "{kept} dash lengths kept"); + assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings); +} + +/// The bytes of text in the document's text objects. +fn text_bytes(doc: &Document) -> usize { + let mut bytes = 0; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + bytes += t.runs.iter().map(|r| r.text.len()).sum::(); + } + }); + bytes +} + +#[test] +fn text_copied_into_array_cells_stops_at_the_text_budget() { + // 1,000 characters of text in an anonymous block placed 10 × 10: 100 KB of copies, past the + // 64 KB tests import with. + let long = "x".repeat(1000); + let blocks = [(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0"), (0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, long.as_str()), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let bytes = text_bytes(&r.document); + assert!(bytes > 0, "some copies are kept"); + assert!(bytes <= 64 << 10, "{bytes} bytes of text"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn a_text_style_naming_an_overlong_font_family_takes_the_default() { + let family = "F".repeat(10_000); + let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Big"), (3, "x.ttf"), (1001, "ACAD"), (1000, family.as_str()), (0, "ENDTAB")]; + let e = [(0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, "Hi"), (7, "Big")]; + let doc = open(&drawing(&[], &tables, &[], &e), &points()).document; + let mut families = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + assert_eq!(families, [vectorcraft_doc::CharStyle::default().font_family]); +} + +#[test] +fn symbol_instances_of_a_long_block_name_stop_at_the_text_budget() { + // A 1,000-byte block name placed 10 × 10: each instance keeps its own copy of the name. + let name = "N".repeat(1000); + let blocks = [(0, "BLOCK"), (2, name.as_str()), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "0"), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, name.as_str()), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "2"), (45, "2")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let mut instances = 0; + r.document.walk(|n| { + if let NodeKind::SymbolInstance { .. } = n.kind { + instances += 1; + } + }); + assert!(instances > 0 && instances < 100, "{instances} instances"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn arrays_of_blocks_of_hidden_art_stop_at_the_work_budget() { + // 500 lines on a hidden layer in an anonymous block placed 50 × 50: they make nothing, but + // each placement looks at all of them (1,250,000 items, past the 1,000,000 tests import with). + let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Off"), (70, "0"), (62, "-1"), (0, "ENDTAB")]; + let mut blocks = vec![(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0")]; + for _ in 0..500 { + blocks.extend([(0, "LINE"), (8, "Off"), (10, "0"), (20, "0"), (11, "1"), (21, "0")]); + } + blocks.push((0, "ENDBLK")); + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "50"), (71, "50"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &tables, &blocks, &insert), &points()); + assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings); +}