Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<svg><path d="M-7-96 6 0 07 4E7"/></svg>` 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.
Expand Down
78 changes: 64 additions & 14 deletions crates/resvg/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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());

Expand All @@ -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<F: FnOnce()>(self, f: F) -> Self;
}
Expand All @@ -96,3 +116,33 @@ impl<T> OptionLog for Option<T> {
})
}
}

#[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);
}
}
18 changes: 17 additions & 1 deletion crates/usvg/src/parser/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,12 +551,28 @@ fn resolve_svg_size(svg: &SvgNode, opt: &Options) -> (Result<Size, Error>, 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;
}
}
Expand Down
22 changes: 22 additions & 0 deletions crates/usvg/tests/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<svg xmlns='http://www.w3.org/2000/svg'>\
<path d='M-7-96 6 0 07 4E7'/>\
</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 = "<svg width='0' height='0' viewBox='0 0 10 20' xmlns='http://www.w3.org/2000/svg'/>";
Expand Down
Loading