use std::collections::BTreeMap;
use kurbo::{Affine, BezPath, Point, Rect};
use pdfrum_object::{Array, Dict, Name, Object};
use crate::Color;
use crate::canvas::{Canvas, Dash, Fill, LineCap, LineJoin, MiterLimit, Paint, Stroke};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Unsupported {
Filter,
Mask,
Text,
Pattern,
OffsetFocalGradient,
BlendMode,
ImageFormat,
}
impl Unsupported {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Filter => "filter",
Self::Mask => "mask",
Self::Text => "text",
Self::Pattern => "pattern",
Self::OffsetFocalGradient => "offset-focal-gradient",
Self::BlendMode => "blend-mode",
Self::ImageFormat => "image-format",
}
}
#[must_use]
pub const fn is_dropped(self) -> bool {
matches!(self, Self::Text | Self::Pattern | Self::ImageFormat)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsupportedItem {
pub what: Unsupported,
pub id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SvgIngestReport {
items: Vec<UnsupportedItem>,
}
impl SvgIngestReport {
#[must_use]
pub fn items(&self) -> &[UnsupportedItem] {
&self.items
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn counts(&self) -> Vec<(Unsupported, usize)> {
let mut counted: BTreeMap<Unsupported, usize> = BTreeMap::new();
for item in &self.items {
*counted.entry(item.what).or_default() += 1;
}
counted.into_iter().collect()
}
fn push(&mut self, what: Unsupported, id: &str) {
self.items.push(UnsupportedItem {
what,
id: id.to_owned(),
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SvgFit {
#[default]
Contain,
Cover,
Stretch,
}
impl SvgFit {
fn place(self, size: kurbo::Size, into: Rect) -> Affine {
let placed = self.fit_box(size, into);
let (sx, sy) = self.scale(size, into);
Affine::new([sx, 0.0, 0.0, -sy, placed.x0, placed.y1])
}
fn fit_box(self, size: kurbo::Size, into: Rect) -> Rect {
let (sx, sy) = self.scale(size, into);
let (width, height) = (size.width * sx, size.height * sy);
let origin = Point::new(
into.x0 + (into.width() - width) / 2.0,
into.y0 + (into.height() - height) / 2.0,
);
Rect::from_origin_size(origin, kurbo::Size::new(width, height))
}
fn scale(self, size: kurbo::Size, into: Rect) -> (f64, f64) {
match self {
Self::Contain => {
let s = (into.width() / size.width).min(into.height() / size.height);
(s, s)
}
Self::Cover => {
let s = (into.width() / size.width).max(into.height() / size.height);
(s, s)
}
Self::Stretch => (into.width() / size.width, into.height() / size.height),
}
}
}
fn parse_options(
resources_dir: Option<std::path::PathBuf>,
#[cfg(feature = "svg-text")] session: &crate::DocEdit<'_>,
) -> usvg::Options<'static> {
#[cfg_attr(
not(feature = "svg-text"),
expect(unused_mut, reason = "text fills it in")
)]
let mut options = usvg::Options {
resources_dir,
..usvg::Options::default()
};
#[cfg(feature = "svg-text")]
{
let (db, default_family) = session.svg_fonts.parts();
options.fontdb = db;
if !default_family.is_empty() {
default_family.clone_into(&mut options.font_family);
}
}
options
}
fn mentions_text(svg: &str) -> bool {
svg.match_indices("<text").any(|(at, _)| {
svg[at + 5..]
.chars()
.next()
.is_none_or(|c| c.is_whitespace() || c == '>' || c == '/')
})
}
fn report_dropped_text(
svg: &str,
report: &mut SvgIngestReport,
#[cfg(feature = "svg-text")] session: &crate::DocEdit<'_>,
) {
#[cfg(feature = "svg-text")]
if !session.svg_fonts.is_empty() {
return;
}
if mentions_text(svg) {
report.push(Unsupported::Text, "");
}
}
impl Canvas<'_, '_> {
pub fn draw_svg(
&mut self,
svg: &str,
into: Rect,
fit: SvgFit,
) -> crate::Result<SvgIngestReport> {
self.draw_svg_from(svg, into, fit, None)
}
pub fn draw_svg_from(
&mut self,
svg: &str,
into: Rect,
fit: SvgFit,
resources_dir: Option<&std::path::Path>,
) -> crate::Result<SvgIngestReport> {
let options = parse_options(
resources_dir.map(Into::into),
#[cfg(feature = "svg-text")]
self.session(),
);
let tree = usvg::Tree::from_str(svg, &options).map_err(crate::Error::Svg)?;
let mut report = SvgIngestReport::default();
report_dropped_text(
svg,
&mut report,
#[cfg(feature = "svg-text")]
self.session(),
);
let placement = fit.place(tree.size().to_kurbo(), into);
self.saved(|c| {
c.clip(into, Fill::NonZero);
c.transform(placement);
let mut walk = Walk {
canvas: c,
report: &mut report,
};
walk.group(tree.root());
});
Ok(report)
}
pub fn place_svg(&mut self, form: &crate::SvgForm, into: Rect, fit: SvgFit) {
let box_size = form.bbox().size();
if box_size.width == 0.0 || box_size.height == 0.0 {
return;
}
self.saved(|c| {
c.clip(into, Fill::NonZero);
c.place_form(form, fit.fit_box(box_size, into));
});
}
}
impl crate::DocEdit<'_> {
pub fn compile_svg(&mut self, svg: &str) -> crate::Result<(crate::SvgForm, SvgIngestReport)> {
self.compile_svg_from(svg, None)
}
pub fn compile_svg_from(
&mut self,
svg: &str,
resources_dir: Option<&std::path::Path>,
) -> crate::Result<(crate::SvgForm, SvgIngestReport)> {
let options = parse_options(
resources_dir.map(Into::into),
#[cfg(feature = "svg-text")]
self,
);
let tree = usvg::Tree::from_str(svg, &options).map_err(crate::Error::Svg)?;
let mut report = SvgIngestReport::default();
report_dropped_text(
svg,
&mut report,
#[cfg(feature = "svg-text")]
self,
);
let size = tree.size().to_kurbo();
let bbox = Rect::from_origin_size(Point::ZERO, size);
let form = self.compile_form(bbox, |c| {
c.transform(SvgFit::Stretch.place(size, bbox));
let mut walk = Walk {
canvas: c,
report: &mut report,
};
walk.group(tree.root());
})?;
Ok((form, report))
}
}
trait ToKurbo {
fn to_kurbo(self) -> kurbo::Size;
}
impl ToKurbo for usvg::Size {
fn to_kurbo(self) -> kurbo::Size {
kurbo::Size::new(f64::from(self.width()), f64::from(self.height()))
}
}
struct Walk<'w, 'a, 'b> {
canvas: &'w mut Canvas<'a, 'b>,
report: &'w mut SvgIngestReport,
}
impl Walk<'_, '_, '_> {
fn group(&mut self, group: &usvg::Group) {
if !group.filters().is_empty() {
self.report.push(Unsupported::Filter, group.id());
}
if group.mask().is_some() {
self.report.push(Unsupported::Mask, group.id());
}
if group.blend_mode() != usvg::BlendMode::Normal {
self.report.push(Unsupported::BlendMode, group.id());
}
let transform = to_affine(group.transform());
let opacity = f64::from(group.opacity().get());
let clip = group.clip_path().map(clip_outline);
self.canvas.saved(|canvas| {
canvas.transform(transform);
if let Some((path, rule)) = clip {
canvas.clip(&path, rule);
}
if opacity < 1.0 {
canvas.opacity(opacity);
}
let mut inner = Walk {
canvas,
report: self.report,
};
for child in group.children() {
inner.node(child);
}
});
}
fn node(&mut self, node: &usvg::Node) {
match node {
usvg::Node::Group(group) => self.group(group),
usvg::Node::Path(path) => self.path(path),
usvg::Node::Image(image) => self.image(image),
usvg::Node::Text(text) => self.text(text),
}
}
#[cfg(feature = "svg-text")]
fn text(&mut self, text: &usvg::Text) {
let flattened = text.flattened();
if flattened.children().is_empty() {
self.report.push(Unsupported::Text, text.id());
return;
}
self.group(flattened);
}
#[cfg(not(feature = "svg-text"))]
fn text(&mut self, text: &usvg::Text) {
self.report.push(Unsupported::Text, text.id());
}
fn path(&mut self, path: &usvg::Path) {
if !path.is_visible() {
return;
}
let outline = to_bez_path(path.data());
let gradient_fill = path.fill().and_then(|fill| match fill.paint() {
usvg::Paint::LinearGradient(_) | usvg::Paint::RadialGradient(_) => {
Some((fill.paint(), fill.rule(), f64::from(fill.opacity().get())))
}
usvg::Paint::Color(_) | usvg::Paint::Pattern(_) => None,
});
if let Some((paint, rule, opacity)) = gradient_fill {
self.shading(paint, &outline, to_fill(rule), opacity, path.id());
}
let fill = if gradient_fill.is_some() {
None
} else {
self.solid(path.fill().map(usvg::Fill::paint), path.id())
.map(|color| {
with_alpha(
color,
path.fill().map_or(1.0, |f| f64::from(f.opacity().get())),
)
})
};
let stroke = path.stroke().and_then(|stroke| {
let color = self.solid(Some(stroke.paint()), path.id())?;
Some(to_stroke(stroke, color))
});
let paint = match (fill, stroke) {
(Some(fill), Some(stroke)) => Paint::FillStroke(fill, stroke),
(Some(fill), None) => Paint::Fill(fill),
(None, Some(stroke)) => Paint::Stroke(stroke),
(None, None) => return,
};
let rule = path.fill().map_or(Fill::NonZero, |f| to_fill(f.rule()));
self.canvas.draw(&outline, paint, rule);
}
fn solid(&mut self, paint: Option<&usvg::Paint>, id: &str) -> Option<Color> {
match paint? {
usvg::Paint::Color(color) => Some(Color::from_rgb8(color.red, color.green, color.blue)),
usvg::Paint::Pattern(_) => {
self.report.push(Unsupported::Pattern, id);
None
}
usvg::Paint::LinearGradient(gradient) => Some(average_stop(gradient.stops())),
usvg::Paint::RadialGradient(gradient) => Some(average_stop(gradient.stops())),
}
}
fn shading(
&mut self,
paint: &usvg::Paint,
outline: &BezPath,
rule: Fill,
opacity: f64,
id: &str,
) {
let (dict, transform) = match paint {
usvg::Paint::LinearGradient(gradient) => {
(axial_shading(gradient), to_affine(gradient.transform()))
}
usvg::Paint::RadialGradient(gradient) => {
#[expect(clippy::float_cmp, reason = "a recognizer, not a measurement")]
let offset = gradient.fx() != gradient.cx()
|| gradient.fy() != gradient.cy()
|| gradient.fr().get() != 0.0;
if offset {
self.report.push(Unsupported::OffsetFocalGradient, id);
}
(radial_shading(gradient), to_affine(gradient.transform()))
}
usvg::Paint::Color(_) | usvg::Paint::Pattern(_) => return,
};
self.canvas.shade(outline, rule, &dict, transform, opacity);
}
fn image(&mut self, image: &usvg::Image) {
if !image.is_visible() {
return;
}
let bytes = match image.kind() {
usvg::ImageKind::JPEG(data) | usvg::ImageKind::PNG(data) => data.clone(),
usvg::ImageKind::GIF(_) | usvg::ImageKind::WEBP(_) => {
self.report.push(Unsupported::ImageFormat, image.id());
return;
}
usvg::ImageKind::SVG(tree) => {
let size = tree.size().to_kurbo();
let placed = image.size().to_kurbo();
let transform = Affine::scale_non_uniform(
placed.width / size.width,
placed.height / size.height,
);
self.canvas.saved(|canvas| {
canvas.transform(transform);
let mut inner = Walk {
canvas,
report: self.report,
};
inner.group(tree.root());
});
return;
}
};
let size = image.size().to_kurbo();
let placed = Rect::new(0.0, 0.0, size.width, size.height);
let Some(embedded) = self.canvas.embed_svg_image(&bytes) else {
self.report.push(Unsupported::ImageFormat, image.id());
return;
};
self.canvas.saved(|canvas| {
canvas.transform(Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, size.height]));
canvas.image(&embedded, placed);
});
}
}
pub(crate) struct DecodedPng {
pub(crate) pixels: Vec<u8>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) format: crate::PixelFormat,
}
pub(crate) fn decode_png(bytes: &[u8]) -> Option<DecodedPng> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().ok()?;
let mut pixels = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut pixels).ok()?;
pixels.truncate(info.buffer_size());
let format = match info.color_type {
png::ColorType::Grayscale => crate::PixelFormat::Gray8,
png::ColorType::Rgb => crate::PixelFormat::Rgb8,
png::ColorType::Rgba => crate::PixelFormat::Rgba8,
png::ColorType::Indexed | png::ColorType::GrayscaleAlpha => return None,
};
Some(DecodedPng {
pixels,
width: info.width,
height: info.height,
format,
})
}
fn with_alpha(color: Color, opacity: f64) -> Color {
#[expect(
clippy::cast_possible_truncation,
reason = "clamped to 0..=1, where every f64 has an f32 within one ulp \
and the loss is far below an alpha step"
)]
let alpha = opacity.clamp(0.0, 1.0) as f32;
color.multiply_alpha(alpha)
}
fn average_stop(stops: &[usvg::Stop]) -> Color {
let mut sum = [0.0_f32; 3];
let mut n = 0.0_f32;
for stop in stops {
let color = stop.color();
sum[0] += f32::from(color.red);
sum[1] += f32::from(color.green);
sum[2] += f32::from(color.blue);
n += 1.0;
}
if n == 0.0 {
return Color::BLACK;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "each channel is a mean of u8s, so it is in 0..=255 by \
construction and the cast cannot lose or wrap"
)]
Color::from_rgb8((sum[0] / n) as u8, (sum[1] / n) as u8, (sum[2] / n) as u8)
}
fn to_affine(t: usvg::Transform) -> Affine {
Affine::new([
f64::from(t.sx),
f64::from(t.ky),
f64::from(t.kx),
f64::from(t.sy),
f64::from(t.tx),
f64::from(t.ty),
])
}
fn to_fill(rule: usvg::FillRule) -> Fill {
match rule {
usvg::FillRule::NonZero => Fill::NonZero,
usvg::FillRule::EvenOdd => Fill::EvenOdd,
}
}
fn to_stroke(stroke: &usvg::Stroke, color: Color) -> Stroke {
let dash = stroke.dasharray().and_then(|lengths| {
let lengths: Vec<f64> = lengths.iter().map(|length| f64::from(*length)).collect();
Dash::new(&lengths, f64::from(stroke.dashoffset().max(0.0)))
});
Stroke {
color: with_alpha(color, f64::from(stroke.opacity().get())),
width: f64::from(stroke.width().get()),
cap: match stroke.linecap() {
usvg::LineCap::Butt => LineCap::Butt,
usvg::LineCap::Round => LineCap::Round,
usvg::LineCap::Square => LineCap::Square,
},
join: match stroke.linejoin() {
usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
usvg::LineJoin::Round => LineJoin::Round,
usvg::LineJoin::Bevel => LineJoin::Bevel,
},
miter_limit: MiterLimit::new(f64::from(stroke.miterlimit().get())),
dash,
}
}
fn to_bez_path(path: &usvg::tiny_skia_path::Path) -> BezPath {
use usvg::tiny_skia_path::PathSegment;
let point = |p: usvg::tiny_skia_path::Point| Point::new(f64::from(p.x), f64::from(p.y));
let mut out = BezPath::new();
for segment in path.segments() {
match segment {
PathSegment::MoveTo(p) => out.move_to(point(p)),
PathSegment::LineTo(p) => out.line_to(point(p)),
PathSegment::QuadTo(c, p) => out.quad_to(point(c), point(p)),
PathSegment::CubicTo(c1, c2, p) => out.curve_to(point(c1), point(c2), point(p)),
PathSegment::Close => out.close_path(),
}
}
out
}
fn clip_outline(clip: &usvg::ClipPath) -> (BezPath, Fill) {
let mut out = BezPath::new();
let mut rule = Fill::NonZero;
collect_clip(
clip.root(),
to_affine(clip.transform()),
&mut out,
&mut rule,
);
(out, rule)
}
fn collect_clip(group: &usvg::Group, at: Affine, out: &mut BezPath, rule: &mut Fill) {
let at = at * to_affine(group.transform());
for child in group.children() {
match child {
usvg::Node::Group(inner) => collect_clip(inner, at, out, rule),
usvg::Node::Path(path) => {
if let Some(fill) = path.fill() {
*rule = to_fill(fill.rule());
}
out.extend(at * to_bez_path(path.data()));
}
usvg::Node::Image(_) | usvg::Node::Text(_) => {}
}
}
}
fn axial_shading(gradient: &usvg::LinearGradient) -> Dict {
shading_dict(
2,
Array::of([
Object::Real(gradient.x1()),
Object::Real(gradient.y1()),
Object::Real(gradient.x2()),
Object::Real(gradient.y2()),
]),
gradient.stops(),
)
}
fn radial_shading(gradient: &usvg::RadialGradient) -> Dict {
shading_dict(
3,
Array::of([
Object::Real(gradient.cx()),
Object::Real(gradient.cy()),
Object::Real(0.0),
Object::Real(gradient.cx()),
Object::Real(gradient.cy()),
Object::Real(gradient.r().get()),
]),
gradient.stops(),
)
}
fn shading_dict(kind: i64, coords: Array, stops: &[usvg::Stop]) -> Dict {
Dict::from_pairs([
(Name::from("ShadingType"), Object::Int(kind)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceRGB")),
),
(Name::from("Coords"), Object::Array(coords)),
(Name::from("Function"), Object::Dict(stitching(stops))),
(
Name::from("Extend"),
Object::Array(Array::of([Object::Bool(true), Object::Bool(true)])),
),
])
}
fn stitching(stops: &[usvg::Stop]) -> Dict {
let rgb = |color: usvg::Color| {
Object::Array(Array::of([
Object::Real(f32::from(color.red) / 255.0),
Object::Real(f32::from(color.green) / 255.0),
Object::Real(f32::from(color.blue) / 255.0),
]))
};
let Some(first) = stops.first() else {
return exponential(rgb(usvg::Color::black()), rgb(usvg::Color::black()));
};
if stops.len() == 1 {
return exponential(rgb(first.color()), rgb(first.color()));
}
let mut functions = Vec::new();
let mut bounds = Vec::new();
let mut encode = Vec::new();
for pair in stops.windows(2) {
let (from, to) = (&pair[0], &pair[1]);
functions.push(Object::Dict(exponential(
rgb(from.color()),
rgb(to.color()),
)));
encode.push(Object::Real(0.0));
encode.push(Object::Real(1.0));
bounds.push(Object::Real(to.offset().get()));
}
bounds.pop();
Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(3)),
(
Name::from("Domain"),
Object::Array(Array::of([Object::Real(0.0), Object::Real(1.0)])),
),
(Name::from("Functions"), Object::Array(Array::of(functions))),
(Name::from("Bounds"), Object::Array(Array::of(bounds))),
(Name::from("Encode"), Object::Array(Array::of(encode))),
])
}
fn exponential(from: Object, to: Object) -> Dict {
Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(2)),
(
Name::from("Domain"),
Object::Array(Array::of([Object::Real(0.0), Object::Real(1.0)])),
),
(Name::from("C0"), from),
(Name::from("C1"), to),
(Name::from("N"), Object::Real(1.0)),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "svg-text"))]
#[test]
fn text_is_seen_in_the_source_because_the_tree_will_not_have_it() {
assert!(mentions_text("<svg><text x='0'>hi</text></svg>"));
assert!(mentions_text("<svg><text/></svg>"));
assert!(!mentions_text(r#"<svg><rect id="textbox"/></svg>"#));
assert!(!mentions_text("<svg><textPath/></svg>"));
}
#[test]
fn contain_centres_and_flips() {
let size = kurbo::Size::new(10.0, 10.0);
let into = Rect::new(0.0, 0.0, 100.0, 200.0);
let at = SvgFit::Contain.place(size, into);
assert_eq!(at * Point::new(0.0, 0.0), Point::new(0.0, 150.0));
assert_eq!(at * Point::new(10.0, 10.0), Point::new(100.0, 50.0));
}
#[test]
fn cover_fills_the_short_axis_and_overflows_the_long_one() {
let at = SvgFit::Cover.place(
kurbo::Size::new(10.0, 10.0),
Rect::new(0.0, 0.0, 100.0, 200.0),
);
assert_eq!(at * Point::new(0.0, 0.0), Point::new(-50.0, 200.0));
assert_eq!(at * Point::new(10.0, 10.0), Point::new(150.0, 0.0));
}
#[test]
fn stretch_fills_both_axes() {
let at = SvgFit::Stretch.place(
kurbo::Size::new(10.0, 20.0),
Rect::new(0.0, 0.0, 100.0, 100.0),
);
assert_eq!(at * Point::new(10.0, 20.0), Point::new(100.0, 0.0));
}
fn png_bytes(group: &usvg::Group) -> Option<Vec<u8>> {
for child in group.children() {
match child {
usvg::Node::Group(inner) => {
if let Some(found) = png_bytes(inner) {
return Some(found);
}
}
usvg::Node::Image(image) => {
if let usvg::ImageKind::PNG(data) = image.kind() {
return Some(data.as_ref().clone());
}
}
usvg::Node::Path(_) | usvg::Node::Text(_) => {}
}
}
None
}
#[test]
fn a_png_decodes_to_samples_and_a_non_png_refuses() {
let svg = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/svg/image_png.svg"
))
.expect("the fixture reads");
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parses");
let bytes = png_bytes(tree.root()).expect("the fixture's image is a PNG");
let decoded = decode_png(&bytes).expect("a truecolour PNG decodes");
assert_eq!((decoded.width, decoded.height), (16, 16));
assert_eq!(decoded.format, crate::PixelFormat::Rgb8);
assert_eq!(decoded.pixels.len(), 16 * 16 * 3);
assert!(decode_png(b"not a png at all").is_none());
}
#[test]
fn a_dropped_construct_is_distinguished_from_an_approximated_one() {
assert!(Unsupported::Pattern.is_dropped());
assert!(!Unsupported::Mask.is_dropped());
}
#[test]
fn every_unsupported_has_a_distinct_name() {
let all = [
Unsupported::Filter,
Unsupported::Mask,
Unsupported::Text,
Unsupported::Pattern,
Unsupported::OffsetFocalGradient,
Unsupported::BlendMode,
Unsupported::ImageFormat,
];
let mut names: Vec<_> = all.iter().map(|u| u.name()).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), all.len());
}
#[test]
fn counts_group_and_sort_by_construct() {
let mut report = SvgIngestReport::default();
report.push(Unsupported::Pattern, "a");
report.push(Unsupported::Filter, "b");
report.push(Unsupported::Pattern, "c");
assert_eq!(
report.counts(),
vec![(Unsupported::Filter, 1), (Unsupported::Pattern, 2)]
);
}
fn gradient_stops(svg: &str) -> Vec<usvg::Stop> {
fn find(group: &usvg::Group) -> Option<Vec<usvg::Stop>> {
for child in group.children() {
match child {
usvg::Node::Group(inner) => {
if let Some(found) = find(inner) {
return Some(found);
}
}
usvg::Node::Path(path) => {
if let Some(usvg::Paint::LinearGradient(g)) =
path.fill().map(usvg::Fill::paint)
{
return Some(g.stops().to_vec());
}
}
usvg::Node::Image(_) | usvg::Node::Text(_) => {}
}
}
None
}
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parses");
find(tree.root()).expect("the document has a gradient-filled path")
}
const TWO_STOP: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<defs><linearGradient id="g"><stop offset="0" stop-color="black"/>
<stop offset="1" stop-color="white"/></linearGradient></defs>
<rect width="10" height="10" fill="url(#g)"/></svg>"#;
#[test]
fn a_two_stop_ramp_is_one_exponential_under_a_stitch() {
let function = stitching(&gradient_stops(TWO_STOP));
assert_eq!(
function.raw(&Name::from("FunctionType")),
Some(&Object::Int(3))
);
let bounds = function
.raw(&Name::from("Bounds"))
.and_then(Object::as_array);
assert_eq!(bounds.map(pdfrum_object::Array::len), Some(0));
let functions = function
.raw(&Name::from("Functions"))
.and_then(Object::as_array);
assert_eq!(functions.map(pdfrum_object::Array::len), Some(1));
}
#[test]
fn a_three_stop_ramp_stitches_two_intervals_at_the_middle_offset() {
const THREE_STOP: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<defs><linearGradient id="g"><stop offset="0" stop-color="black"/>
<stop offset="0.25" stop-color="red"/>
<stop offset="1" stop-color="white"/></linearGradient></defs>
<rect width="10" height="10" fill="url(#g)"/></svg>"#;
let function = stitching(&gradient_stops(THREE_STOP));
let len = |key: &str| {
function
.raw(&Name::from(key))
.and_then(Object::as_array)
.map(pdfrum_object::Array::len)
};
assert_eq!(len("Functions"), Some(2));
assert_eq!(len("Bounds"), Some(1));
assert_eq!(len("Encode"), Some(4));
}
}