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 = "";