Skip to main content

pdfrum_edit/
svg_ingest.rs

1//! SVG into a page: `usvg` resolves the document, this compiles its tree.
2//!
3//! The mirror of `pdfrum-svg`'s export across the same geometry model. Export
4//! decorates a [`RenderDevice`](pdfrum_render::RenderDevice) and writes
5//! `<path>` elements; ingestion walks a resolved `usvg` tree and issues
6//! [`Canvas`] calls, so an SVG logo goes into a PDF as **vectors** rather than
7//! as a resampled bitmap.
8//!
9//! Parsing SVG properly is a project — CSS cascade, `use` expansion, nested
10//! transforms, `viewBox` fitting, gradient coordinate systems — and `usvg`
11//! already does all of it in pure Rust, handing back a tree of paths, groups
12//! and images with every transform and every reference already resolved.
13//! Writing a second incomplete SVG parser is declined here in writing. The
14//! mapping, and every construct the walk cannot carry into PDF, is this
15//! module and [`Unsupported`].
16//!
17//! # What the caller gets back
18//!
19//! [`Canvas::draw_svg`] returns an [`SvgIngestReport`] listing every construct
20//! the walk could not carry into PDF, each as an [`Unsupported`] variant with
21//! the element's id. **Nothing is dropped silently**: that is the property
22//! this module exists to guarantee, the same one `RasterReport` guarantees on
23//! the export side.
24
25use std::collections::BTreeMap;
26
27use kurbo::{Affine, BezPath, Point, Rect};
28use pdfrum_object::{Array, Dict, Name, Object};
29
30use crate::Error;
31use pdfrum_common::Limits;
32use peniko::Color;
33
34/// An ingestion either applies or names why it could not.
35type Result<T> = core::result::Result<T, Error>;
36use crate::canvas::{Canvas, Dash, Fill, LineCap, LineJoin, MiterLimit, Paint, Stroke};
37
38/// A construct in the source SVG that PDF drawing cannot carry.
39///
40/// An enum rather than a message string: a caller that wants to
41/// refuse a filter but tolerate a dropped `<text>` matches on the variant, and
42/// adding a case makes every such match fail to compile.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
44#[non_exhaustive]
45pub enum Unsupported {
46    /// An SVG filter primitive chain — `filter="url(#…)"`.
47    ///
48    /// PDF has no filter model. The only faithful rendering would be to
49    /// rasterize the filtered subtree, which is exactly the blurry result
50    /// ingesting an SVG as vectors exists to avoid, so the subtree is drawn
51    /// **unfiltered** and the loss is reported.
52    Filter,
53    /// A `<mask>` on a group.
54    ///
55    /// A PDF soft mask is expressible, but only through a `/SMask` luminosity
56    /// group whose own content is a second form — a whole second compilation
57    /// path. The group is drawn unmasked and reported.
58    Mask,
59    /// A `<text>` element.
60    ///
61    /// `usvg` is taken with `--no-default-features`, which drops its text
62    /// stack entirely. Text elements therefore never reach the tree at all, so this is
63    /// raised from the *source XML* rather than from the walk, and it is the
64    /// one variant that carries no element id.
65    Text,
66    /// A `<pattern>` fill or stroke.
67    ///
68    /// A PDF tiling pattern is expressible and is future work; today the
69    /// shape is filled with the pattern's average is not attempted at all —
70    /// the shape is skipped and reported, because a wrong colour is a worse
71    /// answer than a reported gap.
72    Pattern,
73    /// A radial gradient whose focal point is offset from its centre, or
74    /// whose focal radius is non-zero.
75    ///
76    /// PDF's type 3 shading has a focal circle too, so the *geometry* maps;
77    /// what does not map is SVG's clamping of a focus that falls outside the
78    /// end circle. Rather than emit a shading that diverges from the source
79    /// where it matters most, the gradient is drawn as its own type 3 shading
80    /// with the focus **moved to the centre**, and the difference is reported.
81    OffsetFocalGradient,
82    /// A blend mode other than `normal` on a group.
83    ///
84    /// PDF has the same separable and non-separable blend modes, but setting
85    /// one meaningfully requires a transparency group `/XObject` around the
86    /// subtree rather than a bare `/ExtGState`. The subtree is drawn with
87    /// normal blending and reported.
88    BlendMode,
89    /// A raster `<image>` in a format this build cannot decode into an
90    /// embedded PDF image: GIF or WebP.
91    ///
92    /// PNG and JPEG both map — JPEG passes through as `/DCTDecode` and PNG is
93    /// decoded to samples. The other two would need a decoder this crate does
94    /// not carry, so the image is skipped and reported.
95    ImageFormat,
96}
97
98impl Unsupported {
99    /// A short, stable name for logs and reports.
100    ///
101    /// ```
102    /// use pdfrum::Unsupported;
103    ///
104    /// assert_eq!(Unsupported::Filter.name(), "filter");
105    /// ```
106    #[must_use]
107    pub const fn name(self) -> &'static str {
108        match self {
109            Self::Filter => "filter",
110            Self::Mask => "mask",
111            Self::Text => "text",
112            Self::Pattern => "pattern",
113            Self::OffsetFocalGradient => "offset-focal-gradient",
114            Self::BlendMode => "blend-mode",
115            Self::ImageFormat => "image-format",
116        }
117    }
118
119    /// Whether the construct was **dropped** rather than approximated.
120    ///
121    /// The distinction a caller acts on: a dropped `<text>` or `<pattern>`
122    /// leaves a visible hole, while an unfiltered group or a recentred focal
123    /// gradient still draws something close. A caller that will accept an
124    /// approximation but not a hole tests this.
125    ///
126    /// ```
127    /// use pdfrum::Unsupported;
128    ///
129    /// assert!(Unsupported::Text.is_dropped());
130    /// assert!(!Unsupported::Filter.is_dropped());
131    /// ```
132    #[must_use]
133    pub const fn is_dropped(self) -> bool {
134        matches!(self, Self::Text | Self::Pattern | Self::ImageFormat)
135    }
136}
137
138/// One construct the walk could not carry, and where it was.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct UnsupportedItem {
141    /// Which construct.
142    pub what: Unsupported,
143    /// The `id` attribute of the element it was on, empty when the source
144    /// gave it none. [`Unsupported::Text`] always carries an empty id: it is
145    /// raised from the source XML, where no tree node survives to name.
146    pub id: String,
147}
148
149/// Everything one `draw_svg` could not carry into the page.
150///
151/// Empty is the meaningful answer — the whole document went in as vectors.
152#[derive(Debug, Clone, Default, PartialEq, Eq)]
153pub struct SvgIngestReport {
154    items: Vec<UnsupportedItem>,
155}
156
157impl SvgIngestReport {
158    /// The items, in walk order.
159    #[must_use]
160    pub fn items(&self) -> &[UnsupportedItem] {
161        &self.items
162    }
163
164    /// Whether the whole document was carried into the page.
165    ///
166    /// ```
167    /// use pdfrum::SvgIngestReport;
168    ///
169    /// assert!(SvgIngestReport::default().is_empty());
170    /// ```
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.items.is_empty()
174    }
175
176    /// How many items each construct accounts for, in [`Unsupported`] order.
177    #[must_use]
178    pub fn counts(&self) -> Vec<(Unsupported, usize)> {
179        let mut counted: BTreeMap<Unsupported, usize> = BTreeMap::new();
180        for item in &self.items {
181            *counted.entry(item.what).or_default() += 1;
182        }
183        counted.into_iter().collect()
184    }
185
186    /// Record one item.
187    fn push(&mut self, what: Unsupported, id: &str) {
188        self.items.push(UnsupportedItem {
189            what,
190            id: id.to_owned(),
191        });
192    }
193}
194
195/// How the SVG's own coordinate box is placed in the destination rectangle.
196///
197/// An enum rather than a `preserve_aspect: bool`, because the three answers
198/// are genuinely different placements and the caller is choosing between
199/// them, not toggling one off.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum SvgFit {
202    /// Scale uniformly until the SVG fits inside the rectangle, and centre
203    /// what is left over. The default, and what `preserveAspectRatio`'s own
204    /// default `xMidYMid meet` means.
205    #[default]
206    Contain,
207    /// Scale uniformly until the SVG covers the rectangle, centred; the
208    /// overflow is clipped to the rectangle.
209    Cover,
210    /// Scale each axis independently so the SVG exactly fills the rectangle,
211    /// distorting it. `preserveAspectRatio="none"`.
212    Stretch,
213}
214
215impl SvgFit {
216    /// The transform placing an SVG of `size` into `into`, y flipped.
217    ///
218    /// SVG's y runs **down** from a top-left origin and the canvas's runs
219    /// **up** from a bottom-left one, so every placement carries a flip. It
220    /// is composed here, once, rather than negated at each draw: a transform
221    /// applied to the whole subtree is the only spelling that also gets
222    /// nested `transform` attributes and gradient coordinate systems right.
223    fn place(self, size: kurbo::Size, into: Rect) -> Affine {
224        let placed = self.fit_box(size, into);
225        let (sx, sy) = self.scale(size, into);
226        // The box, then the flip about its own top edge, so SVG (0,0) lands
227        // at the placed box's top-left.
228        Affine::new([sx, 0.0, 0.0, -sy, placed.x0, placed.y1])
229    }
230
231    /// The rectangle a box of `size` occupies inside `into` under this fit.
232    ///
233    /// The placement without the flip: what a Form `XObject`, whose content
234    /// already carries the flip, is mapped onto. [`SvgFit::place`] is this
235    /// plus the y negation.
236    fn fit_box(self, size: kurbo::Size, into: Rect) -> Rect {
237        let (sx, sy) = self.scale(size, into);
238        let (width, height) = (size.width * sx, size.height * sy);
239        // Centred in the destination, which is what `xMidYMid` means and what
240        // both `Contain` and `Cover` leave over on one axis.
241        let origin = Point::new(
242            into.x0 + (into.width() - width) / 2.0,
243            into.y0 + (into.height() - height) / 2.0,
244        );
245        Rect::from_origin_size(origin, kurbo::Size::new(width, height))
246    }
247
248    /// The per-axis scale this fit applies to a box of `size` inside `into`.
249    fn scale(self, size: kurbo::Size, into: Rect) -> (f64, f64) {
250        match self {
251            Self::Contain => {
252                let s = (into.width() / size.width).min(into.height() / size.height);
253                (s, s)
254            }
255            Self::Cover => {
256                let s = (into.width() / size.width).max(into.height() / size.height);
257                (s, s)
258            }
259            Self::Stretch => (into.width() / size.width, into.height() / size.height),
260        }
261    }
262}
263
264/// The `usvg` parse options this crate uses.
265///
266/// Built here rather than taken from the caller because every field `usvg`
267/// offers either concerns text — whose faces arrive through
268/// [`DocEdit::set_svg_fonts`](crate::EditDoc::set_svg_fonts) instead of
269/// through a `usvg` type on our surface — or is a resource-loading hook whose
270/// defaults are the safe ones. The one exception is the document's own
271/// directory, which a caller who reads an SVG from a file needs so that a
272/// relative `<image href>` resolves.
273///
274/// With `svg-text` off, `fonts` is not read at all: there is no text stack
275/// to give faces to, and the parameter would be the dead option
276/// forbids — so with the feature off the function does not take one.
277fn parse_options(
278    resources_dir: Option<std::path::PathBuf>,
279    #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
280) -> usvg::Options<'static> {
281    #[cfg_attr(
282        not(feature = "svg-text"),
283        expect(unused_mut, reason = "text fills it in")
284    )]
285    let mut options = usvg::Options {
286        resources_dir,
287        ..usvg::Options::default()
288    };
289    #[cfg(feature = "svg-text")]
290    {
291        let (db, default_family) = fonts.parts();
292        options.fontdb = db;
293        // Left at `usvg`'s own default when the set named none, so a document
294        // that does name a family still resolves against what is registered.
295        if !default_family.is_empty() {
296            default_family.clone_into(&mut options.font_family);
297        }
298    }
299    options
300}
301
302/// Whether the source XML contains a `<text>` element `usvg` will have
303/// dropped.
304///
305/// Without a text stack — the `svg-text` feature off, or on with no face
306/// registered — a `<text>` element leaves **no node** in the resolved tree.
307/// There is nothing for the walk to notice,
308/// so reporting it has to happen before the parse, against the bytes.
309///
310/// A substring scan rather than a second XML parse: the question is only
311/// whether to raise a report item, the cost of a false positive is one
312/// spurious line in a report, and pulling in a parser to answer it would
313/// double the dependency for no gain. The `<` is required so that the word
314/// "text" inside an attribute value or a comment does not trigger it.
315fn mentions_text(svg: &str) -> bool {
316    svg.match_indices("<text").any(|(at, _)| {
317        svg[at + 5..]
318            .chars()
319            .next()
320            .is_none_or(|c| c.is_whitespace() || c == '>' || c == '/')
321    })
322}
323
324/// Record the `<text>` this session cannot draw, before the walk that will
325/// not see it.
326///
327/// The one place both ingestion entry points ask the question, so the inline
328/// and the compiled spellings cannot drift apart on it.
329///
330/// It fires whenever the session has no text stack to lay the element out
331/// with — the `svg-text` feature off, or on with **no face registered** — and
332/// the second case is not a special case but the same one: `usvg` with an
333/// empty font database drops a `<text>` exactly as a `usvg` without the
334/// feature does, leaving no node behind. Reporting it here rather than
335/// trusting the walk is what keeps the module's guarantee intact, because a
336/// walk cannot notice something that is not in the tree.
337///
338/// With a face registered the walk sees each element and reports per element
339/// with its id, which is strictly better than this document-wide answer; that
340/// is why this is silent in that case rather than raising a second item.
341fn report_dropped_text(
342    svg: &str,
343    report: &mut SvgIngestReport,
344    #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
345) {
346    #[cfg(feature = "svg-text")]
347    if !fonts.is_empty() {
348        return;
349    }
350    if mentions_text(svg) {
351        report.push(Unsupported::Text, "");
352    }
353}
354
355impl Canvas<'_, '_> {
356    /// Draw an SVG document into `into`, as vectors.
357    ///
358    /// `svg` is the document's source, and `fit` says how its own coordinate
359    /// box is placed in the rectangle — see [`SvgFit`]. The whole drawing is
360    /// scoped: it is wrapped in one `q`/`Q` and clipped to `into`, so nothing
361    /// the SVG does escapes the rectangle the caller named and the canvas's
362    /// own graphics state is untouched afterwards.
363    ///
364    /// The returned [`SvgIngestReport`] lists every construct that could not
365    /// be carried, and **an empty report means the whole document went in**.
366    ///
367    /// # Errors
368    ///
369    /// [`Error::Svg`](Error::Svg) when `usvg` cannot resolve the
370    /// document at all — malformed XML, or an `<svg>` with no usable size.
371    /// A construct that resolves but does not map is a report item, not an
372    /// error.
373    ///
374    /// ```
375    /// use pdfrum::{Document, Rect, SvgFit};
376    ///
377    /// // A plain string with escaped quotes rather than a raw one: a `#`
378    /// // inside a doc comment ends the raw-string hash count.
379    /// const LOGO: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
380    ///     viewBox=\"0 0 10 10\">\
381    ///     <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"#c00\"/></svg>";
382    ///
383    /// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
384    /// let mut edit = doc.edit();
385    /// edit.draw_page(0, |c| {
386    ///     let report = c.draw_svg(LOGO, Rect::new(40.0, 40.0, 140.0, 140.0), SvgFit::Contain);
387    ///     assert!(report.is_ok_and(|r| r.is_empty()));
388    /// })?;
389    /// # Ok::<(), pdfrum::Error>(())
390    /// ```
391    pub fn draw_svg(&mut self, svg: &str, into: Rect, fit: SvgFit) -> Result<SvgIngestReport> {
392        self.draw_svg_from(svg, into, fit, None)
393    }
394
395    /// Draw an SVG document whose relative `<image href>` links resolve
396    /// against `resources_dir`.
397    ///
398    /// [`Canvas::draw_svg`] is this with no directory, which is right for a
399    /// document held in memory; a document read from a file wants the file's
400    /// own directory here, or its linked images do not load.
401    ///
402    /// # Errors
403    ///
404    /// As [`Canvas::draw_svg`].
405    pub fn draw_svg_from(
406        &mut self,
407        svg: &str,
408        into: Rect,
409        fit: SvgFit,
410        resources_dir: Option<&std::path::Path>,
411    ) -> Result<SvgIngestReport> {
412        let options = parse_options(
413            resources_dir.map(Into::into),
414            #[cfg(feature = "svg-text")]
415            self.fonts(),
416        );
417        let tree = usvg::Tree::from_str(svg, &options).map_err(Error::Svg)?;
418
419        let mut report = SvgIngestReport::default();
420        report_dropped_text(
421            svg,
422            &mut report,
423            #[cfg(feature = "svg-text")]
424            self.fonts(),
425        );
426
427        let placement = fit.place(tree.size().to_kurbo(), into);
428        self.saved(|c| {
429            c.clip(into, Fill::NonZero);
430            c.transform(placement);
431            let mut walk = Walk {
432                canvas: c,
433                report: &mut report,
434            };
435            walk.group(tree.root());
436        });
437        Ok(report)
438    }
439
440    /// Place an [`SvgForm`](crate::canvas::SvgForm) compiled by
441    /// [`DocEdit::compile_svg`](crate::EditDoc::compile_svg), fitting its
442    /// box into `into` the way [`Canvas::draw_svg`] fits a document.
443    ///
444    /// The deduplicating spelling of `draw_svg`: the SVG is compiled once and
445    /// this writes one `Do` per placement, so the same logo on twenty pages
446    /// is one content stream rather than twenty. The un-fitted placement is
447    /// this without the fit, stretching the form's box onto the rectangle.
448    ///
449    /// ```
450    /// use pdfrum::{Document, Rect, SvgFit};
451    ///
452    /// const LOGO: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
453    ///     viewBox=\"0 0 10 10\">\
454    ///     <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"#c00\"/></svg>";
455    ///
456    /// let doc = Document::open("tests/fixtures/hello_world_2_pages.pdf")?;
457    /// let mut edit = doc.edit();
458    /// let (logo, report) = edit.compile_svg(LOGO)?;
459    /// assert!(report.is_empty());
460    /// edit.draw_pages(|c| c.place_svg(&logo, Rect::new(10.0, 10.0, 60.0, 60.0), SvgFit::Contain))?;
461    /// # Ok::<(), pdfrum::Error>(())
462    /// ```
463    pub fn place_svg(&mut self, form: &crate::canvas::SvgForm, into: Rect, fit: SvgFit) {
464        // The form's box is the SVG's own, so fitting it into the
465        // destination is the same computation `draw_svg` does on the tree's
466        // size — minus the y flip, which the form's content already carries.
467        let box_size = form.bbox().size();
468        if box_size.width == 0.0 || box_size.height == 0.0 {
469            return;
470        }
471        self.saved(|c| {
472            c.clip(into, Fill::NonZero);
473            c.place_form(form, fit.fit_box(box_size, into));
474        });
475    }
476}
477
478impl crate::EditDoc<'_> {
479    /// Compile an SVG document once, into a Form `XObject` any number of
480    /// pages can place.
481    ///
482    /// The deduplicating half of [`Canvas::draw_svg`]. That method writes the
483    /// SVG's operators **inline** into the page it is drawing on, which is
484    /// right for one placement and wasteful for many: the same logo on twenty
485    /// pages becomes twenty copies of the same content. This compiles the
486    /// document into a single `/Subtype /Form` object with its own `/BBox`
487    /// and `/Resources`, and [`Canvas::place_svg`] then writes one `Do` per
488    /// page against it.
489    ///
490    /// Nothing about the mapping differs — a form's content stream holds the
491    /// same operators `draw_svg` would have written, and the returned
492    /// [`SvgIngestReport`] is the same report. What differs is that the
493    /// operators are written **once**, and that the fit is chosen per
494    /// placement rather than baked in: the form's box is the SVG's own, so
495    /// one compiled logo can be placed [`SvgFit::Contain`] on one page and
496    /// [`SvgFit::Cover`] on another.
497    ///
498    /// # Errors
499    ///
500    /// [`Error::Svg`](Error::Svg) when `usvg` cannot resolve the
501    /// document, exactly as [`Canvas::draw_svg`] reports it.
502    ///
503    /// ```
504    /// use pdfrum::{Document, Rect, SvgFit};
505    ///
506    /// const LOGO: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" \
507    ///     viewBox=\"0 0 10 10\">\
508    ///     <rect width=\"10\" height=\"10\" fill=\"#0a0\"/></svg>";
509    ///
510    /// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
511    /// let mut edit = doc.edit();
512    /// let (logo, _) = edit.compile_svg(LOGO)?;
513    /// assert_eq!(logo.bbox().width(), 10.0);
514    /// # Ok::<(), pdfrum::Error>(())
515    /// ```
516    pub fn compile_svg(
517        &mut self,
518        svg: &str,
519        limits: &Limits,
520        #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
521    ) -> Result<(crate::canvas::SvgForm, SvgIngestReport)> {
522        self.compile_svg_from_with_fonts(
523            svg,
524            None,
525            limits,
526            #[cfg(feature = "svg-text")]
527            fonts,
528        )
529    }
530
531    /// Compile an SVG whose relative `<image href>` links resolve against
532    /// `resources_dir`.
533    ///
534    /// [`DocEdit::compile_svg`](crate::EditDoc::compile_svg) is this with no
535    /// directory, which is right for
536    /// a document held in memory; one read from a file wants the file's own
537    /// directory here, or its linked images do not load. The same pairing
538    /// [`Canvas::draw_svg`] and [`Canvas::draw_svg_from`] have.
539    ///
540    /// # Errors
541    ///
542    /// As [`DocEdit::compile_svg`](crate::EditDoc::compile_svg).
543    pub fn compile_svg_from(
544        &mut self,
545        svg: &str,
546        resources_dir: Option<&std::path::Path>,
547        limits: &Limits,
548    ) -> Result<(crate::canvas::SvgForm, SvgIngestReport)> {
549        self.compile_svg_from_with_fonts(
550            svg,
551            resources_dir,
552            limits,
553            #[cfg(feature = "svg-text")]
554            &crate::svg_text::SvgFonts::new(),
555        )
556    }
557
558    /// [`EditDoc::compile_svg_from`] with the faces the SVG's `<text>` is set
559    /// in; without them a `<text>` draws nothing and is reported instead.
560    ///
561    /// # Errors
562    ///
563    /// As [`EditDoc::compile_svg_from`].
564    pub fn compile_svg_from_with_fonts(
565        &mut self,
566        svg: &str,
567        resources_dir: Option<&std::path::Path>,
568        limits: &Limits,
569        #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
570    ) -> Result<(crate::canvas::SvgForm, SvgIngestReport)> {
571        let options = parse_options(
572            resources_dir.map(Into::into),
573            #[cfg(feature = "svg-text")]
574            fonts,
575        );
576        let tree = usvg::Tree::from_str(svg, &options).map_err(Error::Svg)?;
577
578        let mut report = SvgIngestReport::default();
579        report_dropped_text(
580            svg,
581            &mut report,
582            #[cfg(feature = "svg-text")]
583            fonts,
584        );
585
586        // The form's box is the SVG's own size in a y-**up** space, so a
587        // placement is a plain rectangle-to-rectangle map and the y flip
588        // lives once inside the form rather than at every placement.
589        let size = tree.size().to_kurbo();
590        let bbox = Rect::from_origin_size(Point::ZERO, size);
591        let form = self.compile_form(
592            bbox,
593            limits,
594            #[cfg(feature = "svg-text")]
595            fonts,
596            |c| {
597                c.transform(SvgFit::Stretch.place(size, bbox));
598                let mut walk = Walk {
599                    canvas: c,
600                    report: &mut report,
601                };
602                walk.group(tree.root());
603            },
604        )?;
605        Ok((form, report))
606    }
607}
608
609/// A `usvg::Size` in the workspace's own geometry vocabulary.
610trait ToKurbo {
611    /// The same size as `kurbo`'s.
612    fn to_kurbo(self) -> kurbo::Size;
613}
614
615impl ToKurbo for usvg::Size {
616    fn to_kurbo(self) -> kurbo::Size {
617        kurbo::Size::new(f64::from(self.width()), f64::from(self.height()))
618    }
619}
620
621/// The walk in progress: where the drawing goes and what it could not carry.
622///
623/// A struct rather than two threaded parameters, because every node handler
624/// needs both and the pair is the whole of the walk's state — the transforms
625/// are already resolved into each node by `usvg`, so there is no stack of our
626/// own to carry.
627struct Walk<'w, 'a, 'b> {
628    canvas: &'w mut Canvas<'a, 'b>,
629    report: &'w mut SvgIngestReport,
630}
631
632impl Walk<'_, '_, '_> {
633    /// Draw one group and everything under it.
634    fn group(&mut self, group: &usvg::Group) {
635        // Reported before anything is drawn, so a caller reading the report
636        // in order sees the loss attached to the subtree it applies to.
637        if !group.filters().is_empty() {
638            self.report.push(Unsupported::Filter, group.id());
639        }
640        if group.mask().is_some() {
641            self.report.push(Unsupported::Mask, group.id());
642        }
643        if group.blend_mode() != usvg::BlendMode::Normal {
644            self.report.push(Unsupported::BlendMode, group.id());
645        }
646
647        let transform = to_affine(group.transform());
648        let opacity = f64::from(group.opacity().get());
649        let clip = group.clip_path().map(clip_outline);
650
651        self.canvas.saved(|canvas| {
652            canvas.transform(transform);
653            if let Some((path, rule)) = clip {
654                canvas.clip(&path, rule);
655            }
656            if opacity < 1.0 {
657                canvas.opacity(opacity);
658            }
659            let mut inner = Walk {
660                canvas,
661                report: self.report,
662            };
663            for child in group.children() {
664                inner.node(child);
665            }
666        });
667    }
668
669    /// Draw one node.
670    fn node(&mut self, node: &usvg::Node) {
671        match node {
672            usvg::Node::Group(group) => self.group(group),
673            usvg::Node::Path(path) => self.path(path),
674            usvg::Node::Image(image) => self.image(image),
675            usvg::Node::Text(text) => self.text(text),
676        }
677    }
678
679    /// Draw one `<text>` element, as outlines.
680    ///
681    /// `usvg` has already done the hard half — resolved
682    /// the family against the faces
683    /// [`DocEdit::set_svg_fonts`](crate::EditDoc::set_svg_fonts) registered,
684    /// run the bidi and the shaping, positioned every glyph, applied
685    /// `text-anchor` and `textLength` and any `textPath` — and
686    /// [`flattened`](usvg::Text::flattened) hands back the result as an
687    /// ordinary group of filled paths. So the mapping is: walk that group
688    /// like any other. Glyph outlines *are* paths.
689    ///
690    /// **Outlines rather than embedded text** is the deliberate default: the
691    /// page needs no font embedded and no encoding to get right, and it
692    /// renders identically in every viewer. What it costs is selectable
693    /// text, alongside what embedding would need.
694    ///
695    /// An empty flattened group means `usvg` resolved no face for the
696    /// element's family — the caller registered none, or none that matches —
697    /// so nothing is drawn and the loss is reported per element, with the
698    /// element's own id. A missing face is a reported gap, never a silent one.
699    #[cfg(feature = "svg-text")]
700    fn text(&mut self, text: &usvg::Text) {
701        let flattened = text.flattened();
702        if flattened.children().is_empty() {
703            self.report.push(Unsupported::Text, text.id());
704            return;
705        }
706        self.group(flattened);
707    }
708
709    /// Report one `<text>` this build cannot draw.
710    ///
711    /// Unreachable with `svg-text` off — the text stack is not compiled in,
712    /// so the parser never constructs the variant, and the pre-parse scan in
713    /// [`report_dropped_text`] is what raises the item instead. The arm
714    /// exists because the enum is `usvg`'s and not ours, and a build that did
715    /// carry text must not silently drop it.
716    #[cfg(not(feature = "svg-text"))]
717    fn text(&mut self, text: &usvg::Text) {
718        self.report.push(Unsupported::Text, text.id());
719    }
720
721    /// Draw one path, with whatever of its fill and stroke maps.
722    fn path(&mut self, path: &usvg::Path) {
723        if !path.is_visible() {
724            return;
725        }
726        let outline = to_bez_path(path.data());
727
728        // A gradient fill is a PDF shading, which paints a *region* rather
729        // than taking part in a paint operator: it is written as its own
730        // clipped `sh` and the ordinary paint below then handles the stroke
731        // alone. Splitting the two is what keeps a gradient-filled,
732        // solid-stroked shape correct.
733        let gradient_fill = path.fill().and_then(|fill| match fill.paint() {
734            usvg::Paint::LinearGradient(_) | usvg::Paint::RadialGradient(_) => {
735                Some((fill.paint(), fill.rule(), f64::from(fill.opacity().get())))
736            }
737            usvg::Paint::Color(_) | usvg::Paint::Pattern(_) => None,
738        });
739        if let Some((paint, rule, opacity)) = gradient_fill {
740            self.shading(paint, &outline, to_fill(rule), opacity, path.id());
741        }
742
743        let fill = if gradient_fill.is_some() {
744            None
745        } else {
746            self.solid(path.fill().map(usvg::Fill::paint), path.id())
747                .map(|color| {
748                    with_alpha(
749                        color,
750                        path.fill().map_or(1.0, |f| f64::from(f.opacity().get())),
751                    )
752                })
753        };
754        let stroke = path.stroke().and_then(|stroke| {
755            let color = self.solid(Some(stroke.paint()), path.id())?;
756            Some(to_stroke(stroke, color))
757        });
758
759        let paint = match (fill, stroke) {
760            (Some(fill), Some(stroke)) => Paint::FillStroke(fill, stroke),
761            (Some(fill), None) => Paint::Fill(fill),
762            (None, Some(stroke)) => Paint::Stroke(stroke),
763            (None, None) => return,
764        };
765        let rule = path.fill().map_or(Fill::NonZero, |f| to_fill(f.rule()));
766        self.canvas.draw(&outline, paint, rule);
767    }
768
769    /// The solid colour a paint resolves to, reporting the paints that have
770    /// none.
771    ///
772    /// `None` means "do not paint with this" — either there was no paint at
773    /// all, or it was one whose loss has just been recorded.
774    fn solid(&mut self, paint: Option<&usvg::Paint>, id: &str) -> Option<Color> {
775        match paint? {
776            usvg::Paint::Color(color) => Some(Color::from_rgb8(color.red, color.green, color.blue)),
777            usvg::Paint::Pattern(_) => {
778                self.report.push(Unsupported::Pattern, id);
779                None
780            }
781            // Handled by `shading` on the fill side; a gradient *stroke* has
782            // no PDF spelling short of converting the stroke to its outline,
783            // so it reaches here and is reported as the approximation it is.
784            usvg::Paint::LinearGradient(gradient) => Some(average_stop(gradient.stops())),
785            usvg::Paint::RadialGradient(gradient) => Some(average_stop(gradient.stops())),
786        }
787    }
788
789    /// Paint `outline` with a PDF shading matching `paint`.
790    fn shading(
791        &mut self,
792        paint: &usvg::Paint,
793        outline: &BezPath,
794        rule: Fill,
795        opacity: f64,
796        id: &str,
797    ) {
798        let (dict, transform) = match paint {
799            usvg::Paint::LinearGradient(gradient) => {
800                (axial_shading(gradient), to_affine(gradient.transform()))
801            }
802            usvg::Paint::RadialGradient(gradient) => {
803                // Exact comparisons, deliberately. This asks whether `usvg`
804                // resolved a focus *distinct from* the centre, not whether
805                // two computed quantities are near each other: `usvg` copies
806                // `cx`/`cy` into `fx`/`fy` bit for bit when the source gave
807                // no focus, so equality is the question and a tolerance would
808                // only start reporting gradients that are in fact centred.
809                #[expect(clippy::float_cmp, reason = "a recognizer, not a measurement")]
810                let offset = gradient.fx() != gradient.cx()
811                    || gradient.fy() != gradient.cy()
812                    || gradient.fr().get() != 0.0;
813                if offset {
814                    self.report.push(Unsupported::OffsetFocalGradient, id);
815                }
816                (radial_shading(gradient), to_affine(gradient.transform()))
817            }
818            // Only the two gradient paints reach here; `path` selects on
819            // exactly those variants before calling.
820            usvg::Paint::Color(_) | usvg::Paint::Pattern(_) => return,
821        };
822        self.canvas.shade(outline, rule, &dict, transform, opacity);
823    }
824
825    /// Draw one raster image.
826    fn image(&mut self, image: &usvg::Image) {
827        if !image.is_visible() {
828            return;
829        }
830        let bytes = match image.kind() {
831            usvg::ImageKind::JPEG(data) | usvg::ImageKind::PNG(data) => data.clone(),
832            usvg::ImageKind::GIF(_) | usvg::ImageKind::WEBP(_) => {
833                self.report.push(Unsupported::ImageFormat, image.id());
834                return;
835            }
836            // A nested SVG `usvg` already resolved: walked as a subtree, so
837            // it stays vectors rather than becoming pixels.
838            usvg::ImageKind::SVG(tree) => {
839                let size = tree.size().to_kurbo();
840                let placed = image.size().to_kurbo();
841                let transform = Affine::scale_non_uniform(
842                    placed.width / size.width,
843                    placed.height / size.height,
844                );
845                self.canvas.saved(|canvas| {
846                    canvas.transform(transform);
847                    let mut inner = Walk {
848                        canvas,
849                        report: self.report,
850                    };
851                    inner.group(tree.root());
852                });
853                return;
854            }
855        };
856
857        let size = image.size().to_kurbo();
858        // The image's own box, in SVG coordinates: origin top-left, y down.
859        // `Canvas::image` places a y-up rectangle, so the subtree is flipped
860        // about the box before the image is drawn into it.
861        let placed = Rect::new(0.0, 0.0, size.width, size.height);
862        let Some(embedded) = self.canvas.embed_svg_image(&bytes) else {
863            self.report.push(Unsupported::ImageFormat, image.id());
864            return;
865        };
866        self.canvas.saved(|canvas| {
867            canvas.transform(Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, size.height]));
868            canvas.image(&embedded, placed);
869        });
870    }
871}
872
873/// One decoded PNG, in the shape [`crate::EditDoc::embed_image`] takes.
874pub(crate) struct DecodedPng {
875    pub(crate) pixels: Vec<u8>,
876    pub(crate) width: u32,
877    pub(crate) height: u32,
878    pub(crate) format: crate::PixelFormat,
879}
880
881/// Decode a PNG an SVG `<image>` carried into samples a PDF image can hold.
882///
883/// `None` for anything the decoder refuses. PNG's other bit depths and colour
884/// types are handled by asking the decoder to transform them to eight-bit RGB
885/// or RGBA, which is the one place a decoder earns its keep: a paletted,
886/// interlaced, sixteen-bit or grey-with-alpha source all arrive as one of two
887/// layouts.
888pub(crate) fn decode_png(bytes: &[u8]) -> Option<DecodedPng> {
889    // A `Cursor`, because the decoder wants `BufRead + Seek` and a `&[u8]` is
890    // only the first of the two.
891    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
892    decoder.set_transformations(png::Transformations::normalize_to_color8());
893    let mut reader = decoder.read_info().ok()?;
894    let mut pixels = vec![0; reader.output_buffer_size()?];
895    let info = reader.next_frame(&mut pixels).ok()?;
896    pixels.truncate(info.buffer_size());
897    let format = match info.color_type {
898        png::ColorType::Grayscale => crate::PixelFormat::Gray8,
899        png::ColorType::Rgb => crate::PixelFormat::Rgb8,
900        png::ColorType::Rgba => crate::PixelFormat::Rgba8,
901        // `normalize_to_color8` expands a palette and adds an alpha channel
902        // to grey-with-alpha, so neither reaches here; an indexed image that
903        // somehow did would need a `/Indexed` colour space this path does not
904        // build.
905        png::ColorType::Indexed | png::ColorType::GrayscaleAlpha => return None,
906    };
907    Some(DecodedPng {
908        pixels,
909        width: info.width,
910        height: info.height,
911        format,
912    })
913}
914
915/// `color` with its alpha multiplied by `opacity`.
916fn with_alpha(color: Color, opacity: f64) -> Color {
917    #[expect(
918        clippy::cast_possible_truncation,
919        reason = "clamped to 0..=1, where every f64 has an f32 within one ulp \
920                  and the loss is far below an alpha step"
921    )]
922    let alpha = opacity.clamp(0.0, 1.0) as f32;
923    color.multiply_alpha(alpha)
924}
925
926/// The average of a gradient's stops, for the one place a gradient has to
927/// collapse to a colour: a gradient **stroke**.
928///
929/// A stroke is painted by `S`, which takes a colour and not a shading; the
930/// PDF spelling would be to convert the stroke to its outline and shade that,
931/// which needs a stroke expander this crate does not have. Averaging the
932/// stops keeps the shape visible and roughly the right colour, and it is
933/// the one approximation the
934/// walk makes without a report item — because unlike the reported cases, the
935/// shape is still there and still stroked.
936fn average_stop(stops: &[usvg::Stop]) -> Color {
937    let mut sum = [0.0_f32; 3];
938    let mut n = 0.0_f32;
939    for stop in stops {
940        let color = stop.color();
941        sum[0] += f32::from(color.red);
942        sum[1] += f32::from(color.green);
943        sum[2] += f32::from(color.blue);
944        n += 1.0;
945    }
946    if n == 0.0 {
947        return Color::BLACK;
948    }
949    #[expect(
950        clippy::cast_possible_truncation,
951        clippy::cast_sign_loss,
952        reason = "each channel is a mean of u8s, so it is in 0..=255 by \
953                  construction and the cast cannot lose or wrap"
954    )]
955    Color::from_rgb8((sum[0] / n) as u8, (sum[1] / n) as u8, (sum[2] / n) as u8)
956}
957
958/// A `usvg` transform as an affine.
959fn to_affine(t: usvg::Transform) -> Affine {
960    Affine::new([
961        f64::from(t.sx),
962        f64::from(t.ky),
963        f64::from(t.kx),
964        f64::from(t.sy),
965        f64::from(t.tx),
966        f64::from(t.ty),
967    ])
968}
969
970/// A `usvg` fill rule as the canvas's.
971fn to_fill(rule: usvg::FillRule) -> Fill {
972    match rule {
973        usvg::FillRule::NonZero => Fill::NonZero,
974        usvg::FillRule::EvenOdd => Fill::EvenOdd,
975    }
976}
977
978/// A `usvg` stroke as the canvas's, colour already resolved.
979///
980/// `usvg` resolves `stroke-linecap`, `stroke-linejoin`, `stroke-miterlimit`,
981/// `stroke-dasharray` and `stroke-dashoffset` for us, and PDF has an operator
982/// for each, so all five cross. The one value that does not survive intact is
983/// `LineJoin::MiterClip`: SVG 2 clips the miter at the limit where PDF bevels
984/// it, and `j` offers no third spelling, so it lands on the miter join it is
985/// a variant of rather than on a bevel that would be visibly blunter.
986///
987/// A dash array `usvg` resolved can still be one PDF refuses — an all-zero
988/// `stroke-dasharray` is legal SVG and means solid. `Dash::new` catches those
989/// and the stroke stays solid, which is what the SVG asked for anyway.
990fn to_stroke(stroke: &usvg::Stroke, color: Color) -> Stroke {
991    let dash = stroke.dasharray().and_then(|lengths| {
992        let lengths: Vec<f64> = lengths.iter().map(|length| f64::from(*length)).collect();
993        Dash::new(&lengths, f64::from(stroke.dashoffset().max(0.0)))
994    });
995    Stroke {
996        color: with_alpha(color, f64::from(stroke.opacity().get())),
997        width: f64::from(stroke.width().get()),
998        cap: match stroke.linecap() {
999            usvg::LineCap::Butt => LineCap::Butt,
1000            usvg::LineCap::Round => LineCap::Round,
1001            usvg::LineCap::Square => LineCap::Square,
1002        },
1003        join: match stroke.linejoin() {
1004            usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
1005            usvg::LineJoin::Round => LineJoin::Round,
1006            usvg::LineJoin::Bevel => LineJoin::Bevel,
1007        },
1008        miter_limit: MiterLimit::new(f64::from(stroke.miterlimit().get())),
1009        dash,
1010    }
1011}
1012
1013/// A `tiny_skia_path::Path` as a `kurbo::BezPath`.
1014///
1015/// Segment for segment, with the quadratic raised to the identical cubic
1016/// rather than flattened — `Canvas::write_path` would raise it anyway, and
1017/// doing it here keeps one representation of the curve rather than two.
1018fn to_bez_path(path: &usvg::tiny_skia_path::Path) -> BezPath {
1019    use usvg::tiny_skia_path::PathSegment;
1020
1021    let point = |p: usvg::tiny_skia_path::Point| Point::new(f64::from(p.x), f64::from(p.y));
1022    let mut out = BezPath::new();
1023    for segment in path.segments() {
1024        match segment {
1025            PathSegment::MoveTo(p) => out.move_to(point(p)),
1026            PathSegment::LineTo(p) => out.line_to(point(p)),
1027            PathSegment::QuadTo(c, p) => out.quad_to(point(c), point(p)),
1028            PathSegment::CubicTo(c1, c2, p) => out.curve_to(point(c1), point(c2), point(p)),
1029            PathSegment::Close => out.close_path(),
1030        }
1031    }
1032    out
1033}
1034
1035/// One clip path flattened to a single outline and a rule.
1036///
1037/// `usvg` resolves a `<clipPath>` to a group of paths, and PDF's `W` narrows
1038/// the clip to the *union* of one path's subpaths — so concatenating the
1039/// children's outlines under the nonzero rule is the faithful mapping for the
1040/// common case. A `clipPath` with its own nested `clip-path`, which SVG
1041/// intersects, is not expressible this way.
1042fn clip_outline(clip: &usvg::ClipPath) -> (BezPath, Fill) {
1043    let mut out = BezPath::new();
1044    let mut rule = Fill::NonZero;
1045    collect_clip(
1046        clip.root(),
1047        to_affine(clip.transform()),
1048        &mut out,
1049        &mut rule,
1050    );
1051    (out, rule)
1052}
1053
1054/// Append every path under `group` to `out`, in `group`'s own space.
1055fn collect_clip(group: &usvg::Group, at: Affine, out: &mut BezPath, rule: &mut Fill) {
1056    let at = at * to_affine(group.transform());
1057    for child in group.children() {
1058        match child {
1059            usvg::Node::Group(inner) => collect_clip(inner, at, out, rule),
1060            usvg::Node::Path(path) => {
1061                if let Some(fill) = path.fill() {
1062                    *rule = to_fill(fill.rule());
1063                }
1064                out.extend(at * to_bez_path(path.data()));
1065            }
1066            usvg::Node::Image(_) | usvg::Node::Text(_) => {}
1067        }
1068    }
1069}
1070
1071/// A `/ShadingType 2` dictionary for a linear gradient.
1072fn axial_shading(gradient: &usvg::LinearGradient) -> Dict {
1073    shading_dict(
1074        2,
1075        Array::of([
1076            Object::Real(gradient.x1()),
1077            Object::Real(gradient.y1()),
1078            Object::Real(gradient.x2()),
1079            Object::Real(gradient.y2()),
1080        ]),
1081        gradient.stops(),
1082    )
1083}
1084
1085/// A `/ShadingType 3` dictionary for a radial gradient.
1086///
1087/// The focus is written at the centre: SVG clamps a focus outside the end
1088/// circle in a way PDF's type 3 does not, and the difference is reported as
1089/// [`Unsupported::OffsetFocalGradient`] rather than emitted wrong.
1090fn radial_shading(gradient: &usvg::RadialGradient) -> Dict {
1091    shading_dict(
1092        3,
1093        Array::of([
1094            Object::Real(gradient.cx()),
1095            Object::Real(gradient.cy()),
1096            Object::Real(0.0),
1097            Object::Real(gradient.cx()),
1098            Object::Real(gradient.cy()),
1099            Object::Real(gradient.r().get()),
1100        ]),
1101        gradient.stops(),
1102    )
1103}
1104
1105/// The shading dictionary shared by both gradient types.
1106///
1107/// The stops become one stitching function (type 3) over exponential
1108/// interpolations (type 2), which is how PDF spells a multi-stop ramp: one
1109/// sub-function per adjacent pair, stitched at the stop offsets.
1110fn shading_dict(kind: i64, coords: Array, stops: &[usvg::Stop]) -> Dict {
1111    Dict::from_pairs([
1112        (Name::from("ShadingType"), Object::Int(kind)),
1113        (
1114            Name::from("ColorSpace"),
1115            Object::Name(Name::from("DeviceRGB")),
1116        ),
1117        (Name::from("Coords"), Object::Array(coords)),
1118        (Name::from("Function"), Object::Dict(stitching(stops))),
1119        (
1120            Name::from("Extend"),
1121            Object::Array(Array::of([Object::Bool(true), Object::Bool(true)])),
1122        ),
1123    ])
1124}
1125
1126/// The stitching function over `stops`.
1127fn stitching(stops: &[usvg::Stop]) -> Dict {
1128    let rgb = |color: usvg::Color| {
1129        Object::Array(Array::of([
1130            Object::Real(f32::from(color.red) / 255.0),
1131            Object::Real(f32::from(color.green) / 255.0),
1132            Object::Real(f32::from(color.blue) / 255.0),
1133        ]))
1134    };
1135    // A single stop is a constant colour, which is a type 2 with both ends
1136    // the same rather than a stitch over zero intervals.
1137    let Some(first) = stops.first() else {
1138        return exponential(rgb(usvg::Color::black()), rgb(usvg::Color::black()));
1139    };
1140    if stops.len() == 1 {
1141        return exponential(rgb(first.color()), rgb(first.color()));
1142    }
1143
1144    let mut functions = Vec::new();
1145    let mut bounds = Vec::new();
1146    let mut encode = Vec::new();
1147    for pair in stops.windows(2) {
1148        let (Some(from), Some(to)) = (pair.first(), pair.get(1)) else {
1149            continue;
1150        };
1151        functions.push(Object::Dict(exponential(
1152            rgb(from.color()),
1153            rgb(to.color()),
1154        )));
1155        encode.push(Object::Real(0.0));
1156        encode.push(Object::Real(1.0));
1157        bounds.push(Object::Real(to.offset().get()));
1158    }
1159    // `/Bounds` has one fewer entry than `/Functions`: the last stop's offset
1160    // is the domain's end, not an interior boundary.
1161    bounds.pop();
1162
1163    Dict::from_pairs([
1164        (Name::from("FunctionType"), Object::Int(3)),
1165        (
1166            Name::from("Domain"),
1167            Object::Array(Array::of([Object::Real(0.0), Object::Real(1.0)])),
1168        ),
1169        (Name::from("Functions"), Object::Array(Array::of(functions))),
1170        (Name::from("Bounds"), Object::Array(Array::of(bounds))),
1171        (Name::from("Encode"), Object::Array(Array::of(encode))),
1172    ])
1173}
1174
1175/// One type 2 exponential interpolation from `from` to `to`, linear.
1176fn exponential(from: Object, to: Object) -> Dict {
1177    Dict::from_pairs([
1178        (Name::from("FunctionType"), Object::Int(2)),
1179        (
1180            Name::from("Domain"),
1181            Object::Array(Array::of([Object::Real(0.0), Object::Real(1.0)])),
1182        ),
1183        (Name::from("C0"), from),
1184        (Name::from("C1"), to),
1185        (Name::from("N"), Object::Real(1.0)),
1186    ])
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    // The scan exists only where there is no text stack to put a node in the
1194    // tree; with `svg-text` the walk sees the element itself.
1195    #[cfg(not(feature = "svg-text"))]
1196    #[test]
1197    fn text_is_seen_in_the_source_because_the_tree_will_not_have_it() {
1198        assert!(mentions_text("<svg><text x='0'>hi</text></svg>"));
1199        assert!(mentions_text("<svg><text/></svg>"));
1200        // The word inside an attribute must not trigger it.
1201        assert!(!mentions_text(r#"<svg><rect id="textbox"/></svg>"#));
1202        assert!(!mentions_text("<svg><textPath/></svg>"));
1203    }
1204
1205    #[test]
1206    fn contain_centres_and_flips() {
1207        let size = kurbo::Size::new(10.0, 10.0);
1208        let into = Rect::new(0.0, 0.0, 100.0, 200.0);
1209        let at = SvgFit::Contain.place(size, into);
1210        // SVG's top-left corner lands at the destination's top-left, and its
1211        // bottom-left at the bottom of the fitted square, centred vertically.
1212        assert_eq!(at * Point::new(0.0, 0.0), Point::new(0.0, 150.0));
1213        assert_eq!(at * Point::new(10.0, 10.0), Point::new(100.0, 50.0));
1214    }
1215
1216    #[test]
1217    fn cover_fills_the_short_axis_and_overflows_the_long_one() {
1218        // A square into a tall rectangle: `Cover` scales to the *height*, so
1219        // the SVG is 200 wide in a 100-wide box and hangs 50 off each side.
1220        // `draw_svg` clips to the destination, which is what makes the
1221        // overflow a crop rather than a spill.
1222        let at = SvgFit::Cover.place(
1223            kurbo::Size::new(10.0, 10.0),
1224            Rect::new(0.0, 0.0, 100.0, 200.0),
1225        );
1226        assert_eq!(at * Point::new(0.0, 0.0), Point::new(-50.0, 200.0));
1227        assert_eq!(at * Point::new(10.0, 10.0), Point::new(150.0, 0.0));
1228    }
1229
1230    #[test]
1231    fn stretch_fills_both_axes() {
1232        let at = SvgFit::Stretch.place(
1233            kurbo::Size::new(10.0, 20.0),
1234            Rect::new(0.0, 0.0, 100.0, 100.0),
1235        );
1236        assert_eq!(at * Point::new(10.0, 20.0), Point::new(100.0, 0.0));
1237    }
1238
1239    /// The bytes of the first PNG `<image>` under `group`.
1240    fn png_bytes(group: &usvg::Group) -> Option<Vec<u8>> {
1241        for child in group.children() {
1242            match child {
1243                usvg::Node::Group(inner) => {
1244                    if let Some(found) = png_bytes(inner) {
1245                        return Some(found);
1246                    }
1247                }
1248                usvg::Node::Image(image) => {
1249                    if let usvg::ImageKind::PNG(data) = image.kind() {
1250                        return Some(data.as_ref().clone());
1251                    }
1252                }
1253                usvg::Node::Path(_) | usvg::Node::Text(_) => {}
1254            }
1255        }
1256        None
1257    }
1258
1259    #[test]
1260    fn a_png_decodes_to_samples_and_a_non_png_refuses() {
1261        // The fixture corpus's own embedded tile, 16x16 truecolour. Decoding
1262        // it is the path `Canvas::embed_svg_image` takes for a PNG
1263        // `<image>`; refusing anything else is what raises
1264        // `Unsupported::ImageFormat`.
1265        let svg = std::fs::read_to_string(concat!(
1266            env!("CARGO_MANIFEST_DIR"),
1267            "/tests/fixtures/svg/image_png.svg"
1268        ))
1269        .expect("the fixture reads");
1270        let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parses");
1271        let bytes = png_bytes(tree.root()).expect("the fixture's image is a PNG");
1272        let decoded = decode_png(&bytes).expect("a truecolour PNG decodes");
1273        assert_eq!((decoded.width, decoded.height), (16, 16));
1274        assert_eq!(decoded.format, crate::PixelFormat::Rgb8);
1275        assert_eq!(decoded.pixels.len(), 16 * 16 * 3);
1276
1277        assert!(decode_png(b"not a png at all").is_none());
1278    }
1279
1280    #[test]
1281    fn a_dropped_construct_is_distinguished_from_an_approximated_one() {
1282        assert!(Unsupported::Pattern.is_dropped());
1283        assert!(!Unsupported::Mask.is_dropped());
1284    }
1285
1286    #[test]
1287    fn every_unsupported_has_a_distinct_name() {
1288        let all = [
1289            Unsupported::Filter,
1290            Unsupported::Mask,
1291            Unsupported::Text,
1292            Unsupported::Pattern,
1293            Unsupported::OffsetFocalGradient,
1294            Unsupported::BlendMode,
1295            Unsupported::ImageFormat,
1296        ];
1297        let mut names: Vec<_> = all.iter().map(|u| u.name()).collect();
1298        names.sort_unstable();
1299        names.dedup();
1300        assert_eq!(names.len(), all.len());
1301    }
1302
1303    #[test]
1304    fn counts_group_and_sort_by_construct() {
1305        let mut report = SvgIngestReport::default();
1306        report.push(Unsupported::Pattern, "a");
1307        report.push(Unsupported::Filter, "b");
1308        report.push(Unsupported::Pattern, "c");
1309        assert_eq!(
1310            report.counts(),
1311            vec![(Unsupported::Filter, 1), (Unsupported::Pattern, 2)]
1312        );
1313    }
1314
1315    /// The stops of the one linear gradient in `svg`.
1316    ///
1317    /// `usvg::Stop` has no public constructor, so the stops a function test
1318    /// needs come from a real parse rather than from a literal — which also
1319    /// keeps the test honest about what `usvg` actually hands us.
1320    fn gradient_stops(svg: &str) -> Vec<usvg::Stop> {
1321        fn find(group: &usvg::Group) -> Option<Vec<usvg::Stop>> {
1322            for child in group.children() {
1323                match child {
1324                    usvg::Node::Group(inner) => {
1325                        if let Some(found) = find(inner) {
1326                            return Some(found);
1327                        }
1328                    }
1329                    usvg::Node::Path(path) => {
1330                        if let Some(usvg::Paint::LinearGradient(g)) =
1331                            path.fill().map(usvg::Fill::paint)
1332                        {
1333                            return Some(g.stops().to_vec());
1334                        }
1335                    }
1336                    usvg::Node::Image(_) | usvg::Node::Text(_) => {}
1337                }
1338            }
1339            None
1340        }
1341        let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parses");
1342        find(tree.root()).expect("the document has a gradient-filled path")
1343    }
1344
1345    const TWO_STOP: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
1346      <defs><linearGradient id="g"><stop offset="0" stop-color="black"/>
1347      <stop offset="1" stop-color="white"/></linearGradient></defs>
1348      <rect width="10" height="10" fill="url(#g)"/></svg>"#;
1349
1350    #[test]
1351    fn a_two_stop_ramp_is_one_exponential_under_a_stitch() {
1352        let function = stitching(&gradient_stops(TWO_STOP));
1353        assert_eq!(
1354            function.raw(&Name::from("FunctionType")),
1355            Some(&Object::Int(3))
1356        );
1357        // Two stops make one interval, so there is no *interior* boundary.
1358        let bounds = function
1359            .raw(&Name::from("Bounds"))
1360            .and_then(Object::as_array);
1361        assert_eq!(bounds.map(pdfrum_object::Array::len), Some(0));
1362        let functions = function
1363            .raw(&Name::from("Functions"))
1364            .and_then(Object::as_array);
1365        assert_eq!(functions.map(pdfrum_object::Array::len), Some(1));
1366    }
1367
1368    #[test]
1369    fn a_three_stop_ramp_stitches_two_intervals_at_the_middle_offset() {
1370        const THREE_STOP: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
1371          <defs><linearGradient id="g"><stop offset="0" stop-color="black"/>
1372          <stop offset="0.25" stop-color="red"/>
1373          <stop offset="1" stop-color="white"/></linearGradient></defs>
1374          <rect width="10" height="10" fill="url(#g)"/></svg>"#;
1375        let function = stitching(&gradient_stops(THREE_STOP));
1376        let len = |key: &str| {
1377            function
1378                .raw(&Name::from(key))
1379                .and_then(Object::as_array)
1380                .map(pdfrum_object::Array::len)
1381        };
1382        assert_eq!(len("Functions"), Some(2));
1383        // One fewer bound than functions, at the interior stop's own offset.
1384        assert_eq!(len("Bounds"), Some(1));
1385        assert_eq!(len("Encode"), Some(4));
1386    }
1387}