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}