From 30209caad802de6744a916358862bc128722deb8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 15:13:21 -0500 Subject: [PATCH] feat(gif,image-codecs): add indexed/interlaced GIF, jpeg_progressive_scans, and JP2 decode (#1358) * feat(gif,image-codecs): add indexed/interlaced GIF, jpeg_progressive_scans, and JP2 decode * chore: update Cargo.lock for craft-image-codecs jp2 dep * fix(image-codecs,svg): deref jp2 ch and re-export resvg from craft_svg::tree::draw --- Cargo.lock | 1 + crates/gif/src/write.rs | 52 ++++++ crates/image-codecs/Cargo.toml | 1 + crates/image-codecs/src/codecs.rs | 1 + crates/image-codecs/src/codecs/jp2.rs | 242 +++++++++++++++++++++++++ crates/image-codecs/src/codecs/jpeg.rs | 3 + crates/image-codecs/src/format.rs | 14 +- crates/image-codecs/src/lib.rs | 4 +- crates/image-codecs/src/options.rs | 4 + crates/svg/src/tree/draw.rs | 2 + 10 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 crates/image-codecs/src/codecs/jp2.rs diff --git a/Cargo.lock b/Cargo.lock index 3d8cf4a7b..52b28f0bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,6 +1852,7 @@ dependencies = [ "exr", "flate2", "half", + "hayro-jpeg2000", "image", "image-webp", "jpeg-encoder 0.6.1", diff --git a/crates/gif/src/write.rs b/crates/gif/src/write.rs index bc8a4be97..259aa78f1 100644 --- a/crates/gif/src/write.rs +++ b/crates/gif/src/write.rs @@ -194,6 +194,45 @@ pub fn animation(width: u32, height: u32, frames: &[RgbaFrame<'_>], options: Opt writer.finish() } +/// Encode a single pre-quantized indexed-color frame as a GIF89a image. +/// +/// `palette` holds 1 to 256 RGB triplets; `indices` is `width * height` row-major palette indices. +/// When `interlaced` is true, rows are reordered into GIF's 4-pass interlaced order before writing. +pub fn encode_indexed( + width: u32, + height: u32, + palette: &[[u8; 3]], + indices: &[u8], + transparent: Option, + interlaced: bool, +) -> Result, Error> { + let (w, h) = dimensions(width, height)?; + let expected = usize::from(w) * usize::from(h); + if indices.len() != expected { + return Err(Error::FrameLength { index: 0, actual: indices.len(), expected }); + } + if palette.is_empty() || palette.len() > 256 { + return Err(Error::Encode("GIF palette must hold 1 to 256 entries".into())); + } + let flat_palette: Vec = palette.iter().flat_map(|c| *c).collect(); + let buffer = if interlaced { std::borrow::Cow::Owned(interlace(indices, usize::from(w))) } else { std::borrow::Cow::Borrowed(indices) }; + let mut enc = gif::Encoder::new(Vec::new(), w, h, &flat_palette).map_err(Error::from)?; + let frame = gif::Frame { width: w, height: h, buffer, transparent, interlaced, ..gif::Frame::default() }; + enc.write_frame(&frame).map_err(Error::from)?; + enc.into_inner().map_err(Error::from) +} + +/// The rows of a `width`-wide image in GIF's interlaced order: every 8th row from 0, every 8th +/// from 4, every 4th from 2, then every 2nd from 1. +fn interlace(px: &[u8], width: usize) -> Vec { + let rows: Vec<&[u8]> = px.chunks(width.max(1)).collect(); + [(0, 8), (4, 8), (2, 4), (1, 2)] + .into_iter() + .flat_map(|(start, step)| rows.iter().skip(start).step_by(step)) + .flat_map(|r| r.iter().copied()) + .collect() +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -288,4 +327,17 @@ mod tests { assert_eq!(decoded.frames[0].rgba, red); assert_eq!(decoded.frames[1].rgba, blue); } + + #[test] + fn encode_indexed_validates_and_writes_gif() { + let palette = [[255, 0, 0], [0, 255, 0]]; + let indices = [0u8, 1, 1, 0, 0, 1, 1, 0, 0]; + let bytes = super::encode_indexed(3, 3, &palette, &indices, Some(1), true).unwrap(); + let info = crate::scan(&bytes, 4).unwrap(); + assert_eq!((info.width, info.height), (3, 3)); + assert_eq!(info.frames.len(), 1); + assert_eq!(info.frames[0].transparent, Some(1)); + assert!(super::encode_indexed(2, 2, &[], &[0; 4], None, false).is_err()); + assert!(super::encode_indexed(2, 2, &palette, &[0; 3], None, false).is_err()); + } } diff --git a/crates/image-codecs/Cargo.toml b/crates/image-codecs/Cargo.toml index b893c347a..3cf6109c5 100644 --- a/crates/image-codecs/Cargo.toml +++ b/crates/image-codecs/Cargo.toml @@ -33,6 +33,7 @@ craft-jpeg = { workspace = true } craft-metadata = { workspace = true } zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } craft-heif = { workspace = true, optional = true } +hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } diff --git a/crates/image-codecs/src/codecs.rs b/crates/image-codecs/src/codecs.rs index 4c4c22388..da856aaad 100644 --- a/crates/image-codecs/src/codecs.rs +++ b/crates/image-codecs/src/codecs.rs @@ -10,6 +10,7 @@ pub(crate) mod gif_frames; pub(crate) mod gif_palette; pub(crate) mod heif; pub(crate) mod iff; +pub(crate) mod jp2; pub(crate) mod jpeg; pub(crate) mod jxl; pub(crate) mod pcx; diff --git a/crates/image-codecs/src/codecs/jp2.rs b/crates/image-codecs/src/codecs/jp2.rs new file mode 100644 index 000000000..763ac9c9c --- /dev/null +++ b/crates/image-codecs/src/codecs/jp2.rs @@ -0,0 +1,242 @@ +//! JPEG 2000 (JP2 container and raw J2K codestream) decoding via `hayro-jpeg2000`. +//! +//! Decodes within `Limits`, rejecting unsupported JPX multi-layer/composition boxes and +//! checking declared SIZ/tile/palette/channel-definition bounds before pixel allocation. + +use hayro_jpeg2000::{ColorSpace, DecodeSettings, DecoderContext, Image as Jp2Image}; + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Jp2; +const JP2_SIGNATURE: &[u8] = b"\0\0\0\x0cjP \r\n\x87\n"; +const MAX_PROFILE: usize = 4 * 1024 * 1024; +const MAX_TILES: u64 = 65_535; +const TILE_COMPONENT_BYTES: u64 = 256; + +/// Boxes at one JP2 nesting level. Invalid lengths stop the walk at that level. +fn boxes(b: &[u8]) -> impl Iterator { + let mut at = 0usize; + std::iter::from_fn(move || { + let header = b.get(at..at.checked_add(8)?)?; + let size = u32::from_be_bytes(header.get(..4)?.try_into().ok()?); + let (length, head) = match size { + 0 => (b.len().checked_sub(at)?, 8usize), + 1 => (usize::try_from(u64::from_be_bytes(b.get(at.checked_add(8)?..at.checked_add(16)?)?.try_into().ok()?)).ok()?, 16usize), + n => (n as usize, 8usize), + }; + if length < head { + return None; + } + let end = at.checked_add(length)?; + let data = b.get(at.checked_add(head)?..end)?; + let ty = header.get(4..8)?; + at = end; + Some((ty, data)) + }) +} + +fn unsupported_jpx_content(bytes: &[u8]) -> bool { + fn association_has_content(data: &[u8], depth: usize) -> bool { + if depth >= 16 { + return true; + } + boxes(data).any(|(ty, child)| { + matches!(ty, b"jp2c" | b"jpch" | b"jplh" | b"comp" | b"ftbl") || (ty == b"asoc" && association_has_content(child, depth + 1)) + }) + } + let mut codestreams = 0u8; + for (ty, data) in boxes(bytes) { + match ty { + b"jp2c" => { + codestreams = codestreams.saturating_add(1); + if codestreams > 1 { + return true; + } + } + b"jpch" | b"jplh" | b"comp" | b"ftbl" => return true, + b"asoc" if association_has_content(data, 0) => return true, + _ => {} + } + } + false +} + +fn codestream_size(bytes: &[u8]) -> Option<(u64, u32, u32, u64)> { + let stream = if bytes.starts_with(b"\xff\x4f") { bytes } else { boxes(bytes).find_map(|(ty, data)| (ty == b"jp2c").then_some(data))? }; + if stream.get(..4)? != b"\xff\x4f\xff\x51" { + return None; + } + let length = u16::from_be_bytes(stream.get(4..6)?.try_into().ok()?) as usize; + let count = u16::from_be_bytes(stream.get(40..42)?.try_into().ok()?) as usize; + if count == 0 || length != 38usize.checked_add(count.checked_mul(3)?)? || stream.get(4..4usize.checked_add(length)?)?.len() != length { + return None; + } + let be32 = |range: std::ops::Range| stream.get(range).and_then(|v| v.try_into().ok()).map(u32::from_be_bytes); + let width = be32(8..12)?.checked_sub(be32(16..20)?)?; + let height = be32(12..16)?.checked_sub(be32(20..24)?)?; + let tiles = |grid: u32, offset: u32, tile: u32| match (grid.checked_sub(offset), tile) { + (Some(span), 1..) => Some(u64::from(span).div_ceil(u64::from(tile))), + _ => None, + }; + let across = tiles(be32(8..12)?, be32(32..36)?, be32(24..28)?)?; + let down = tiles(be32(12..16)?, be32(36..40)?, be32(28..32)?)?; + Some((count as u64, width, height, across.checked_mul(down)?)) +} + +fn single_header_boxes(bytes: &[u8]) -> bool { + let mut headers = boxes(bytes).filter(|(ty, _)| *ty == b"jp2h"); + let (Some((_, header)), None) = (headers.next(), headers.next()) else { + return false; + }; + [b"pclr", b"cmap", b"cdef"].iter().all(|kind| boxes(header).filter(|(ty, _)| ty == kind).count() <= 1) +} + +fn channel_definitions(bytes: &[u8]) -> Option { + if bytes.starts_with(b"\xff\x4f") { + return None; + } + let header = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data))?; + let cdef = boxes(header).find_map(|(ty, data)| (ty == b"cdef").then_some(data))?; + let count = cdef.get(..2).and_then(|n| n.try_into().ok()).map_or(0, |n: [u8; 2]| usize::from(u16::from_be_bytes(n))); + let Some(entries) = cdef.get(2..).filter(|rest| count > 0 && rest.len() == count * 6) else { + return Some(0); + }; + let mut defs: Vec<[u16; 3]> = + entries.as_chunks::<6>().0.iter().map(|&[a, b, c, d, e, f]| [[a, b], [c, d], [e, f]].map(u16::from_be_bytes)).collect(); + defs.sort_unstable(); + let opacity = defs.last().is_some_and(|&[_, kind, association]| kind == 1 && association == 0); + let colors = defs.len() - usize::from(opacity); + let mut seen = vec![false; colors]; + let layout_ok = defs.iter().enumerate().all(|(i, &[channel, kind, association])| { + usize::from(channel) == i + && (i >= colors + || (kind == 0 + && usize::from(association).checked_sub(1).and_then(|slot| seen.get_mut(slot)).is_some_and(|s| !std::mem::replace(s, true)))) + }); + Some(if layout_ok { defs.len() as u64 } else { 0 }) +} + +fn palette_columns(bytes: &[u8], components: u64) -> Result, ()> { + if bytes.starts_with(b"\xff\x4f") { + return Ok(None); + } + let Some(header) = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data)) else { + return Ok(None); + }; + let Some(palette) = boxes(header).find_map(|(ty, data)| (ty == b"pclr").then_some(data)) else { + return Ok(None); + }; + let columns = palette.get(2).copied().ok_or(())?; + let depths = palette.get(3..3 + usize::from(columns)).ok_or(())?; + if columns == 0 || depths.iter().any(|&d| d & 0x80 != 0 || (d & 0x7f) + 1 > 31) { + return Err(()); + } + let Some(mapping) = boxes(header).find_map(|(ty, data)| (ty == b"cmap").then_some(data)) else { + return Ok(Some(u64::from(columns))); + }; + if mapping.len() != usize::from(columns) * 4 { + return Err(()); + } + for &[c0, c1, kind, column] in mapping.as_chunks::<4>().0 { + let valid = u64::from(u16::from_be_bytes([c0, c1])) < components + && match kind { + 0 => true, + 1 => column < columns, + _ => false, + }; + if !valid { + return Err(()); + } + } + Ok(Some(u64::from(columns))) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let malformed = |msg: &str| CodecError::malformed(F, msg); + if bytes.starts_with(JP2_SIGNATURE) && unsupported_jpx_content(bytes) { + return Err(CodecError::unsupported(F, "unsupported color space or JPX content")); + } + if bytes.starts_with(JP2_SIGNATURE) && !single_header_boxes(bytes) { + return Err(malformed("invalid JP2 header boxes")); + } + let (codestream_channels, declared_w, declared_h, tile_count) = codestream_size(bytes).ok_or_else(|| malformed("invalid codestream SIZ"))?; + if tile_count == 0 || tile_count > MAX_TILES { + return Err(malformed("invalid tile count")); + } + let max_alloc = limits.max_alloc; + let declared_pixels = u64::from(declared_w).checked_mul(u64::from(declared_h)); + if declared_w == 0 + || declared_h == 0 + || declared_w > limits.max_width + || declared_h > limits.max_height + || declared_pixels.is_none_or(|n| n > limits.max_pixels) + || declared_pixels.and_then(|n| n.checked_mul(codestream_channels)).and_then(|n| n.checked_mul(4)).is_none_or(|n| n > max_alloc) + || tile_count.checked_mul(codestream_channels).and_then(|n| n.checked_mul(TILE_COMPONENT_BYTES)).is_none_or(|n| n > max_alloc) + { + return Err(CodecError::LimitExceeded(format!("dimensions {declared_w}x{declared_h} exceed decode limits"))); + } + let header = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data)).unwrap_or_default(); + if boxes(header).any(|(ty, data)| ty == b"colr" && data.len() > MAX_PROFILE + 3) { + return Err(CodecError::unsupported(F, "ICC profile exceeds 4 MiB limit")); + } + let palette = palette_columns(bytes, codestream_channels).map_err(|()| malformed("invalid JP2 palette"))?; + let palette_rows = boxes(header).find_map(|(ty, data)| (ty == b"pclr").then(|| data.get(..2)).flatten()); + if let (Some(columns), Some(&[r0, r1])) = (palette, palette_rows) + && u64::from(u16::from_be_bytes([r0, r1])) * (columns * 8 + 24) > max_alloc + { + return Err(CodecError::LimitExceeded("JP2 palette exceeds max_alloc".into())); + } + let image = Jp2Image::new(bytes, &DecodeSettings::default()).map_err(|e| malformed(&format!("{e:?}")))?; + let (w, h) = (image.width(), image.height()); + let color = image.color_space(); + let (channels, icc) = match color { + ColorSpace::Gray => (1u8, None), + ColorSpace::RGB => (3u8, None), + ColorSpace::CMYK => (4u8, None), + ColorSpace::Icc { num_channels: ch @ (1 | 3 | 4), profile } if profile.len() <= MAX_PROFILE => (*ch, Some(profile.to_vec())), + _ => return Err(CodecError::unsupported(F, "unsupported JPEG 2000 color space")), + }; + let has_alpha = image.has_alpha(); + let stride = u64::from(channels) + u64::from(has_alpha); + if palette.is_some_and(|columns| columns != stride) { + return Err(malformed("JP2 palette columns do not match output channels")); + } + if channel_definitions(bytes).is_some_and(|count| count != stride || count != palette.unwrap_or(codestream_channels)) { + return Err(malformed("JP2 channel definitions do not match output channels")); + } + let layout = match (channels, has_alpha) { + (1, false) => ChannelLayout::Gray, + (1, true) => ChannelLayout::GrayA, + (3, false) => ChannelLayout::Rgb, + (3, true) => ChannelLayout::Rgba, + (4, false) => ChannelLayout::Cmyk, + (4, true) => ChannelLayout::CmykA, + _ => return Err(CodecError::unsupported(F, "unsupported channel count")), + }; + limits.check(w, h, layout, SampleType::U8)?; + let plane_channels = codestream_channels.max(stride).max(palette.unwrap_or(0)); + let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| CodecError::LimitExceeded("pixel count overflow".into()))?; + let size = pixels.checked_mul(stride).ok_or_else(|| CodecError::LimitExceeded("sample count overflow".into()))?; + let plane_bytes = + pixels.checked_mul(plane_channels).and_then(|n| n.checked_mul(4)).ok_or_else(|| CodecError::LimitExceeded("plane overflow".into()))?; + let scratch_bytes = plane_bytes + .checked_mul(2) + .and_then(|n| n.checked_add(pixels.checked_mul(8)?)) + .ok_or_else(|| CodecError::LimitExceeded("scratch overflow".into()))?; + let working = size + .checked_add(plane_bytes) + .and_then(|n| n.checked_add(scratch_bytes)) + .ok_or_else(|| CodecError::LimitExceeded("memory overflow".into()))?; + if working > max_alloc { + return Err(CodecError::LimitExceeded(format!("working set {working} exceeds max_alloc {max_alloc}"))); + } + let len = usize::try_from(size).map_err(|_| CodecError::LimitExceeded("buffer does not fit in memory".into()))?; + let mut samples = vec![0u8; len]; + image.decode_into(&mut samples, &mut DecoderContext::default()).map_err(|e| malformed(&format!("{e:?}")))?; + let mut out = Image::from_raw(w, h, layout, SampleType::U8, samples)?; + out.icc = icc; + Ok(out) +} diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 173b4b740..bcd86ad5a 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -479,6 +479,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result "Cineon", Format::Dicom => "DICOM", Format::Iff => "Amiga IFF", + Format::Jp2 => "JPEG 2000", } } @@ -201,6 +206,7 @@ impl Format { Format::Cineon => &["cin"], Format::Dicom => &["dcm", "dicom", "dc3"], Format::Iff => &["iff", "lbm", "ilbm"], + Format::Jp2 => &["jp2", "j2k", "j2c", "jpf", "jpx"], } } @@ -229,6 +235,7 @@ impl Format { Format::Cineon => "image/x-cin", Format::Dicom => "application/dicom", Format::Iff => "image/x-iff", + Format::Jp2 => "image/jp2", } } @@ -303,6 +310,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base }, + Format::Jp2 => FormatCaps { read: true, write: false, layouts: &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA], icc: true, ..base }, } } @@ -320,6 +328,10 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") { return Some(Format::Jxl); } + // JPEG 2000 container signature box or raw J2K codestream (SOC + SIZ). + if b.starts_with(b"\0\0\0\x0cjP \r\n\x87\n") || b.starts_with(b"\xff\x4f\xff\x51") { + return Some(Format::Jp2); + } if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") { return Some(Format::Tiff); } diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index b4358bba1..50292cbf4 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -61,7 +61,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jp2, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -102,6 +102,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Iff => iff::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), + Format::Jp2 => jp2::decode(bytes, l), Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), }?; @@ -446,6 +447,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result iff::encode(image, plan), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), + Format::Jp2 => Err(CodecError::unsupported(format, "JPEG 2000 is import-only; save as PNG or TIFF instead")), Format::Jxl => jxl::encode(image, plan), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => { via_image::encode(format, image, plan, opts) diff --git a/crates/image-codecs/src/options.rs b/crates/image-codecs/src/options.rs index 9674b4edf..ed96b8733 100644 --- a/crates/image-codecs/src/options.rs +++ b/crates/image-codecs/src/options.rs @@ -299,6 +299,9 @@ pub struct EncodeOptions { pub jpeg_chroma_subsampling: bool, /// JPEG scan layout (baseline, baseline optimized or progressive). pub jpeg_format: JpegFormat, + /// Optional number of progressive JPEG scans (typically 3..=5). Ignored unless + /// `jpeg_format` is [`JpegFormat::Progressive`]. + pub jpeg_progressive_scans: Option, pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, @@ -336,6 +339,7 @@ impl Default for EncodeOptions { jpeg_quality: 90, jpeg_chroma_subsampling: true, jpeg_format: JpegFormat::Baseline, + jpeg_progressive_scans: None, png_compression: PngCompression::Default, png_interlaced: false, png_stereo: None, diff --git a/crates/svg/src/tree/draw.rs b/crates/svg/src/tree/draw.rs index a7bdfd80f..f83653284 100644 --- a/crates/svg/src/tree/draw.rs +++ b/crates/svg/src/tree/draw.rs @@ -24,6 +24,8 @@ use std::collections::HashMap; use usvg::Transform; +#[cfg(feature = "tree-render")] +pub use resvg; #[cfg(feature = "tree-render")] pub use resvg::tiny_skia;