Skip to main content

typst_library/visualize/image/
svg.rs

1use std::hash::{Hash, Hasher};
2use std::sync::{Arc, Mutex};
3
4use comemo::Tracked;
5use ecow::eco_format;
6use rustc_hash::FxHashMap;
7use siphasher::sip128::{Hasher128, SipHasher13};
8use typst_syntax::FileId;
9
10use crate::World;
11use crate::diag::{
12    FileError, LoadError, LoadResult, ReportTextPos, StrResult, bail,
13    format_xml_like_error,
14};
15use crate::foundations::{Bytes, PathOrStr};
16use crate::layout::Axes;
17use crate::text::{
18    Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
19};
20use crate::visualize::VectorFormat;
21use crate::visualize::image::raster::{ExchangeFormat, RasterFormat};
22use crate::visualize::image::{ImageFormat, determine_format_from_path};
23
24/// A decoded SVG.
25#[derive(Clone, Hash)]
26pub struct SvgImage(Arc<SvgImageInner>);
27
28/// The internal representation of an [`SvgImage`].
29struct SvgImageInner {
30    data: Bytes,
31    size: Axes<f64>,
32    font_hash: u128,
33    tree: usvg::Tree,
34}
35
36impl SvgImage {
37    /// Decode an SVG image without fonts.
38    #[comemo::memoize]
39    #[typst_macros::time(name = "load svg")]
40    pub fn new(data: Bytes) -> LoadResult<SvgImage> {
41        let tree =
42            usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?;
43        Ok(Self(Arc::new(SvgImageInner {
44            data,
45            size: tree_size(&tree),
46            font_hash: 0,
47            tree,
48        })))
49    }
50
51    /// Decode an SVG image with access to fonts and linked images.
52    #[comemo::memoize]
53    #[typst_macros::time(name = "load svg")]
54    pub fn with_fonts_images(
55        data: Bytes,
56        world: Tracked<dyn World + '_>,
57        families: &[&str],
58        svg_file: Option<FileId>,
59    ) -> LoadResult<SvgImage> {
60        let book = world.book();
61        let font_resolver = Mutex::new(FontResolver::new(world, book, families));
62        let image_resolver = Mutex::new(ImageResolver::new(world, svg_file));
63        let tree = usvg::Tree::from_data(
64            &data,
65            &usvg::Options {
66                font_resolver: usvg::FontResolver {
67                    select_font: Box::new(|font, db| {
68                        font_resolver.lock().unwrap().select_font(font, db)
69                    }),
70                    select_fallback: Box::new(|c, exclude_fonts, db| {
71                        font_resolver.lock().unwrap().select_fallback(
72                            c,
73                            exclude_fonts,
74                            db,
75                        )
76                    }),
77                },
78                image_href_resolver: usvg::ImageHrefResolver {
79                    resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
80                    resolve_string: Box::new(|href, _opts| {
81                        image_resolver.lock().unwrap().load(href)
82                    }),
83                },
84                ..base_options()
85            },
86        )
87        .map_err(format_usvg_error)?;
88        if let Some(err) = image_resolver.into_inner().unwrap().error {
89            return Err(err);
90        }
91        let font_hash = font_resolver.into_inner().unwrap().finish();
92        Ok(Self(Arc::new(SvgImageInner {
93            data,
94            size: tree_size(&tree),
95            font_hash,
96            tree,
97        })))
98    }
99
100    /// The raw image data.
101    pub fn data(&self) -> &Bytes {
102        &self.0.data
103    }
104
105    /// The SVG's width in pixels.
106    pub fn width(&self) -> f64 {
107        self.0.size.x
108    }
109
110    /// The SVG's height in pixels.
111    pub fn height(&self) -> f64 {
112        self.0.size.y
113    }
114
115    /// Accesses the usvg tree.
116    pub fn tree(&self) -> &usvg::Tree {
117        &self.0.tree
118    }
119}
120
121impl Hash for SvgImageInner {
122    fn hash<H: Hasher>(&self, state: &mut H) {
123        // An SVG might contain fonts, which must be incorporated into the hash.
124        // We can't hash a usvg tree directly, but the raw SVG data + a hash of
125        // all used fonts gives us something similar.
126        self.data.hash(state);
127        self.font_hash.hash(state);
128    }
129}
130
131/// The base conversion options, to be extended with font-related options
132/// because those can change across the document.
133fn base_options() -> usvg::Options<'static> {
134    usvg::Options {
135        // Disable usvg's default to "Times New Roman".
136        font_family: String::new(),
137
138        // We don't override the DPI here, because we already
139        // force the image into the corresponding DPI by setting
140        // the width and height. Changing the DPI only trips up
141        // the logic in `resvg`.
142
143        // Override usvg's resource loading defaults.
144        resources_dir: None,
145        image_href_resolver: usvg::ImageHrefResolver {
146            resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
147            resolve_string: Box::new(|_, _| None),
148        },
149
150        ..Default::default()
151    }
152}
153
154/// The pixel size of an SVG.
155fn tree_size(tree: &usvg::Tree) -> Axes<f64> {
156    Axes::new(tree.size().width() as f64, tree.size().height() as f64)
157}
158
159/// Format the user-facing SVG decoding error message.
160fn format_usvg_error(error: usvg::Error) -> LoadError {
161    let error = match error {
162        usvg::Error::NotAnUtf8Str => "file is not valid UTF-8",
163        usvg::Error::MalformedGZip => "file is not compressed correctly",
164        usvg::Error::ElementsLimitReached => "file is too large",
165        usvg::Error::InvalidSize => "width, height, or viewbox is invalid",
166        usvg::Error::ParsingFailed(error) => return format_xml_like_error("SVG", error),
167    };
168    LoadError::text(ReportTextPos::None, "failed to parse SVG", error)
169}
170
171/// Provides Typst's fonts to usvg.
172struct FontResolver<'a> {
173    /// Typst's font book.
174    book: &'a FontBook,
175    /// The world we use to load fonts.
176    world: Tracked<'a, dyn World + 'a>,
177    /// The active list of font families at the location of the SVG.
178    families: &'a [&'a str],
179    /// A mapping from Typst font indices to fontdb IDs.
180    to_id: FxHashMap<usize, Option<fontdb::ID>>,
181    /// The reverse mapping.
182    from_id: FxHashMap<fontdb::ID, Font>,
183    /// Accumulates a hash of all used fonts.
184    hasher: SipHasher13,
185}
186
187impl<'a> FontResolver<'a> {
188    /// Create a new font provider.
189    fn new(
190        world: Tracked<'a, dyn World + 'a>,
191        book: &'a FontBook,
192        families: &'a [&'a str],
193    ) -> Self {
194        Self {
195            book,
196            world,
197            families,
198            to_id: FxHashMap::default(),
199            from_id: FxHashMap::default(),
200            hasher: SipHasher13::new(),
201        }
202    }
203
204    /// Returns a hash of all used fonts.
205    fn finish(self) -> u128 {
206        self.hasher.finish128().as_u128()
207    }
208}
209
210impl FontResolver<'_> {
211    /// Select a font.
212    fn select_font(
213        &mut self,
214        font: &usvg::Font,
215        db: &mut Arc<fontdb::Database>,
216    ) -> Option<fontdb::ID> {
217        let variant = FontVariant {
218            style: font.style().into(),
219            weight: FontWeight::from_number(font.weight()),
220            stretch: font.stretch().into(),
221        };
222
223        // Find a family that is available.
224        font.families()
225            .iter()
226            .filter_map(|family| match family {
227                usvg::FontFamily::Named(named) => Some(named.as_str()),
228                // We don't support generic families at the moment.
229                _ => None,
230            })
231            .chain(self.families.iter().copied())
232            .filter_map(|named| self.book.select(&named.to_lowercase(), variant))
233            .find_map(|index| self.get_or_load(index, db))
234    }
235
236    /// Select a fallback font.
237    fn select_fallback(
238        &mut self,
239        c: char,
240        exclude_fonts: &[fontdb::ID],
241        db: &mut Arc<fontdb::Database>,
242    ) -> Option<fontdb::ID> {
243        // Get the font info of the originally selected font.
244        let like = exclude_fonts
245            .first()
246            .and_then(|first| self.from_id.get(first))
247            .map(|font| font.info());
248
249        // usvg doesn't provide a variant in the fallback handler, but
250        // `exclude_fonts` is actually never empty in practice. Still, we
251        // prefer to fall back to the default variant rather than panicking
252        // in case that changes in the future.
253        let variant = like.map(|info| info.variant).unwrap_or_default();
254
255        // Select the font.
256        let index =
257            self.book.select_fallback(like, variant, c.encode_utf8(&mut [0; 4]))?;
258
259        self.get_or_load(index, db).filter(|id| !exclude_fonts.contains(id))
260    }
261
262    /// Tries to retrieve the ID for the index or loads the font, allocating
263    /// a new ID.
264    fn get_or_load(
265        &mut self,
266        index: usize,
267        db: &mut Arc<fontdb::Database>,
268    ) -> Option<fontdb::ID> {
269        self.to_id
270            .get(&index)
271            .copied()
272            .unwrap_or_else(|| self.load(index, db))
273    }
274
275    /// Tries to load the font with the given index in the font book into the
276    /// database and returns its ID.
277    fn load(
278        &mut self,
279        index: usize,
280        db: &mut Arc<fontdb::Database>,
281    ) -> Option<fontdb::ID> {
282        let font = self.world.font(index)?;
283        let info = font.info();
284        let variant = info.variant;
285        let id = Arc::make_mut(db).push_face_info(fontdb::FaceInfo {
286            id: fontdb::ID::dummy(),
287            source: fontdb::Source::Binary(Arc::new(font.data().clone())),
288            index: font.index(),
289            families: vec![(
290                info.family.clone(),
291                ttf_parser::Language::English_UnitedStates,
292            )],
293            post_script_name: String::new(),
294            style: match variant.style {
295                FontStyle::Normal => fontdb::Style::Normal,
296                FontStyle::Italic => fontdb::Style::Italic,
297                FontStyle::Oblique => fontdb::Style::Oblique,
298            },
299            weight: fontdb::Weight(variant.weight.to_number()),
300            stretch: match variant.stretch.round() {
301                FontStretch::ULTRA_CONDENSED => ttf_parser::Width::UltraCondensed,
302                FontStretch::EXTRA_CONDENSED => ttf_parser::Width::ExtraCondensed,
303                FontStretch::CONDENSED => ttf_parser::Width::Condensed,
304                FontStretch::SEMI_CONDENSED => ttf_parser::Width::SemiCondensed,
305                FontStretch::NORMAL => ttf_parser::Width::Normal,
306                FontStretch::SEMI_EXPANDED => ttf_parser::Width::SemiExpanded,
307                FontStretch::EXPANDED => ttf_parser::Width::Expanded,
308                FontStretch::EXTRA_EXPANDED => ttf_parser::Width::ExtraExpanded,
309                FontStretch::ULTRA_EXPANDED => ttf_parser::Width::UltraExpanded,
310                _ => unreachable!(),
311            },
312            monospaced: info.flags.contains(FontFlags::MONOSPACE),
313        });
314
315        font.hash(&mut self.hasher);
316
317        self.to_id.insert(index, Some(id));
318        self.from_id.insert(id, font);
319
320        Some(id)
321    }
322}
323
324/// Resolves linked images in an SVG.
325/// (Linked SVG images from an SVG are not supported yet.)
326struct ImageResolver<'a> {
327    /// The world used to load linked images.
328    world: Tracked<'a, dyn World + 'a>,
329    /// Parent folder of the SVG file, used to resolve hrefs to linked images, if any.
330    svg_file: Option<FileId>,
331    /// The first error that occurred when loading a linked image, if any.
332    error: Option<LoadError>,
333}
334
335impl<'a> ImageResolver<'a> {
336    fn new(world: Tracked<'a, dyn World + 'a>, svg_file: Option<FileId>) -> Self {
337        Self { world, svg_file, error: None }
338    }
339
340    /// Load a linked image or return None if a previous image caused an error,
341    /// or if the linked image failed to load.
342    /// Only the first error message is retained.
343    fn load(&mut self, href: &str) -> Option<usvg::ImageKind> {
344        if self.error.is_some() {
345            return None;
346        }
347        match self.load_or_error(href) {
348            Ok(image) => Some(image),
349            Err(err) => {
350                self.error = Some(LoadError::text(
351                    ReportTextPos::None,
352                    eco_format!("failed to load linked image {href} in SVG"),
353                    err,
354                ));
355                None
356            }
357        }
358    }
359
360    /// Load a linked image or return an error message string.
361    fn load_or_error(&mut self, href: &str) -> StrResult<usvg::ImageKind> {
362        // If the href starts with "file://", strip this prefix to construct an ordinary path.
363        let href = href.strip_prefix("file://").unwrap_or(href);
364
365        // Do not accept absolute hrefs. They would be parsed in Typst in a way
366        // that is not compatible with their interpretation in the SVG standard.
367        if href.starts_with("/") {
368            bail!("absolute paths are not allowed");
369        }
370
371        // Exit early if the href is an URL.
372        if let Some(pos) = href.find("://") {
373            let scheme = &href[..pos];
374            if scheme
375                .chars()
376                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
377            {
378                bail!("URLs are not allowed");
379            }
380        }
381
382        // Resolve the path to the linked image.
383        let href_file = PathOrStr::Str(href.into())
384            .resolve_if_some(self.svg_file)
385            .map_err(|hinted| hinted.message().clone())?
386            .intern();
387
388        // Load image if file can be accessed.
389        match self.world.file(href_file) {
390            Ok(bytes) => {
391                let arc_data = Arc::new(bytes.into_vec());
392                let format = match determine_format_from_path(href_file.vpath()) {
393                    Some(format) => Some(format),
394                    None => ImageFormat::detect(&arc_data),
395                };
396                match format {
397                    Some(ImageFormat::Vector(vector_format)) => match vector_format {
398                        VectorFormat::Svg => {
399                            Err("SVG images are not supported yet".into())
400                        }
401                        VectorFormat::Pdf => {
402                            Err("PDF documents are not supported".into())
403                        }
404                    },
405                    Some(ImageFormat::Raster(raster_format)) => match raster_format {
406                        RasterFormat::Exchange(exchange_format) => {
407                            match exchange_format {
408                                ExchangeFormat::Gif => Ok(usvg::ImageKind::GIF(arc_data)),
409                                ExchangeFormat::Jpg => {
410                                    Ok(usvg::ImageKind::JPEG(arc_data))
411                                }
412                                ExchangeFormat::Png => Ok(usvg::ImageKind::PNG(arc_data)),
413                                ExchangeFormat::Webp => {
414                                    Ok(usvg::ImageKind::WEBP(arc_data))
415                                }
416                            }
417                        }
418                        RasterFormat::Pixel(_) => {
419                            Err("pixel formats are not supported".into())
420                        }
421                    },
422                    None => Err("unknown image format".into()),
423                }
424            }
425            // TODO: Somehow unify this with `impl Display for FileError`.
426            Err(err) => Err(match err {
427                FileError::NotFound(path) => {
428                    eco_format!("file not found, searched at {}", path.display())
429                }
430                FileError::AccessDenied => "access denied".into(),
431                FileError::IsDirectory => "is a directory".into(),
432                FileError::Other(Some(msg)) => msg,
433                FileError::Other(None) => "unspecified error".into(),
434                _ => eco_format!("unexpected error: {err}"),
435            }),
436        }
437    }
438}