typst_library/visualize/image/
svg.rs

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