diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2e52f17..d0b295196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ This changelog also contains important changes in dependencies. This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API. +### Fixed +- Freeze when rendering a size-less SVG whose content bounding box is huge. The + auto-derived canvas size is now capped, so inputs like + `` no longer produce a multi-gigabyte + canvas. (#939) +- Panic in `resvg::render`/`render_node` when rendering into a very large + (heavily upscaled) pixmap, caused by an `i32` overflow while computing the + clip region. (#939) + ## [0.47.0] 2026-02-05 This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API. diff --git a/crates/resvg/src/lib.rs b/crates/resvg/src/lib.rs index 45a4d9d6c..5f1a50c73 100644 --- a/crates/resvg/src/lib.rs +++ b/crates/resvg/src/lib.rs @@ -37,13 +37,7 @@ pub fn render( pixmap: &mut tiny_skia::PixmapMut, ) { let target_size = tiny_skia::IntSize::from_wh(pixmap.width(), pixmap.height()).unwrap(); - let max_bbox = tiny_skia::IntRect::from_xywh( - -(target_size.width() as i32) * 2, - -(target_size.height() as i32) * 2, - target_size.width() * 5, - target_size.height() * 5, - ) - .unwrap(); + let max_bbox = max_bbox(target_size); let ctx = render::Context { max_bbox }; render::render_nodes(tree.root(), &ctx, transform, pixmap); @@ -67,13 +61,7 @@ pub fn render_node( let bbox = node.abs_layer_bounding_box()?; let target_size = tiny_skia::IntSize::from_wh(pixmap.width(), pixmap.height()).unwrap(); - let max_bbox = tiny_skia::IntRect::from_xywh( - -(target_size.width() as i32) * 2, - -(target_size.height() as i32) * 2, - target_size.width() * 5, - target_size.height() * 5, - ) - .unwrap(); + let max_bbox = max_bbox(target_size); transform = transform.pre_translate(-bbox.x(), -bbox.y()); @@ -83,6 +71,38 @@ pub fn render_node( Some(()) } +/// Builds a generous clipping region around the target pixmap. +/// +/// The region extends two viewport-lengths into the negative direction and is +/// five viewport-lengths wide/tall. For very large pixmaps (e.g. a tiny SVG +/// scaled up by a huge factor) these multiplications would exceed `i32::MAX`, +/// which previously made `IntRect::from_xywh` return `None` and the following +/// `.unwrap()` panic. We compute in `i64` and clamp to a valid range instead, +/// so an over-large target degrades into a still-generous clip region rather +/// than a crash. See https://github.com/linebender/resvg/issues/939. +fn max_bbox(target_size: tiny_skia::IntSize) -> tiny_skia::IntRect { + let w = target_size.width() as i64; + let h = target_size.height() as i64; + + // `IntRect::from_xywh` takes `u32` extents but rejects any that exceed + // `i32::MAX` (or whose `x + width` overflows `i32`). Clamp accordingly. + let clamp_pos = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32; + let clamp_ext = |v: i64| v.clamp(0, i32::MAX as i64) as u32; + + let x = clamp_pos(-w * 2); + let y = clamp_pos(-h * 2); + let width = clamp_ext(w * 5); + let height = clamp_ext(h * 5); + + // `x`/`y` are non-positive and the extents are bounded by `i32::MAX`, so + // `x + width` / `y + height` cannot overflow and this always succeeds; the + // fallback to the bare target rect (which is always valid) only guards + // against future changes. + tiny_skia::IntRect::from_xywh(x, y, width, height).unwrap_or_else(|| { + tiny_skia::IntRect::from_xywh(0, 0, target_size.width(), target_size.height()).unwrap() + }) +} + pub(crate) trait OptionLog { fn log_none(self, f: F) -> Self; } @@ -96,3 +116,33 @@ impl OptionLog for Option { }) } } + +#[cfg(test)] +mod tests { + use super::max_bbox; + + #[test] + fn max_bbox_handles_huge_target() { + // Regression test for https://github.com/linebender/resvg/issues/939. + // Rendering into a very tall pixmap (e.g. a tiny SVG scaled up so its + // height becomes ~571 million pixels) used to panic because + // `height * 5` exceeds `i32::MAX` and `IntRect::from_xywh` returned + // `None`. `max_bbox` must clamp instead of panicking. + let size = tiny_skia::IntSize::from_wh(100, 571_428_544).unwrap(); + let bbox = max_bbox(size); + // The extents are clamped to the valid `i32` range. + assert!(bbox.width() <= i32::MAX as u32); + assert!(bbox.height() <= i32::MAX as u32); + } + + #[test] + fn max_bbox_small_target_is_unchanged() { + // For ordinary sizes the region keeps its 2x-negative / 5x-extent shape. + let size = tiny_skia::IntSize::from_wh(100, 200).unwrap(); + let bbox = max_bbox(size); + assert_eq!(bbox.x(), -200); + assert_eq!(bbox.y(), -400); + assert_eq!(bbox.width(), 500); + assert_eq!(bbox.height(), 1000); + } +} diff --git a/crates/usvg/src/parser/converter.rs b/crates/usvg/src/parser/converter.rs index 38bdbdbff..7873c1c50 100644 --- a/crates/usvg/src/parser/converter.rs +++ b/crates/usvg/src/parser/converter.rs @@ -551,12 +551,28 @@ fn resolve_svg_size(svg: &SvgNode, opt: &Options) -> (Result, bool) (size.ok_or(Error::InvalidSize), restore_viewbox) } +/// The maximum dimension of a canvas derived from the content's bounding box. +/// +/// When an SVG declares no `width`/`height`/`viewBox`, we fall back to sizing +/// the canvas to the content's bounding box. A single far-away coordinate +/// (e.g. the malformed path `M-7-96 6 0 07 4E7`, where `4E7` is 40 000 000) +/// can blow this up to tens of millions of pixels, producing a multi-gigabyte +/// canvas that takes minutes to render and encode. Capping each derived +/// dimension keeps such inputs fast (or makes them fail fast at pixmap +/// creation) instead of freezing, while being far larger than any realistic +/// auto-sized SVG. See https://github.com/linebender/resvg/issues/939. +const MAX_AUTO_SIZE: f32 = i16::MAX as f32; + /// Calculates SVG's size and viewBox in case there were not set. /// /// Simply iterates over all nodes and calculates a bounding box. fn calculate_svg_bbox(tree: &mut Tree) { let bbox = tree.root.abs_bounding_box(); - if let Some(size) = Size::from_wh(bbox.right(), bbox.bottom()) { + // `min` also discards NaN (returning `MAX_AUTO_SIZE`), so a degenerate + // bounding box cannot produce a non-finite canvas size. + let width = bbox.right().min(MAX_AUTO_SIZE); + let height = bbox.bottom().min(MAX_AUTO_SIZE); + if let Some(size) = Size::from_wh(width, height) { tree.size = size; } } diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs index 905bbc0db..2a40628d4 100644 --- a/crates/usvg/tests/parser.rs +++ b/crates/usvg/tests/parser.rs @@ -227,6 +227,28 @@ fn size_detection_5() { assert_eq!(tree.size(), usvg::Size::from_wh(100.0, 100.0).unwrap()); } +#[test] +fn size_detection_huge_content_bbox() { + // Regression test for https://github.com/linebender/resvg/issues/939. + // Without an explicit `width`/`height`/`viewBox`, the canvas is derived + // from the content's bounding box. The `4E7` (= 40 000 000) coordinate + // used to produce a 7 x 40 000 000 canvas (~1.1 GB), which froze rendering + // and PNG encoding for tens of seconds. The auto-derived size must now be + // clamped to a sane maximum. + let svg = "\ + \ + "; + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + let max = i16::MAX as f32; + assert!( + tree.size().height() <= max, + "auto-derived height {} exceeds the cap {}", + tree.size().height(), + max + ); + assert_eq!(tree.size(), usvg::Size::from_wh(7.0, max).unwrap()); +} + #[test] fn invalid_size_1() { let svg = "";