summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/visualize/image
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/visualize/image')
-rw-r--r--crates/typst-library/src/visualize/image/mod.rs360
-rw-r--r--crates/typst-library/src/visualize/image/raster.rs286
-rw-r--r--crates/typst-library/src/visualize/image/svg.rs289
3 files changed, 935 insertions, 0 deletions
diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs
new file mode 100644
index 00000000..359db252
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/mod.rs
@@ -0,0 +1,360 @@
+//! Image handling.
+
+mod raster;
+mod svg;
+
+pub use self::raster::{RasterFormat, RasterImage};
+pub use self::svg::SvgImage;
+
+use std::fmt::{self, Debug, Formatter};
+use std::sync::Arc;
+
+use comemo::Tracked;
+use ecow::EcoString;
+use typst_syntax::{Span, Spanned};
+use typst_utils::LazyHash;
+
+use crate::diag::{At, SourceResult, StrResult};
+use crate::engine::Engine;
+use crate::foundations::{
+ cast, elem, func, scope, Bytes, Cast, Content, NativeElement, Packed, Show, Smart,
+ StyleChain,
+};
+use crate::layout::{BlockElem, Length, Rel, Sizing};
+use crate::loading::Readable;
+use crate::model::Figurable;
+use crate::text::LocalName;
+use crate::World;
+
+/// A raster or vector graphic.
+///
+/// You can wrap the image in a [`figure`] to give it a number and caption.
+///
+/// Like most elements, images are _block-level_ by default and thus do not
+/// integrate themselves into adjacent paragraphs. To force an image to become
+/// inline, put it into a [`box`].
+///
+/// # Example
+/// ```example
+/// #figure(
+/// image("molecular.jpg", width: 80%),
+/// caption: [
+/// A step in the molecular testing
+/// pipeline of our lab.
+/// ],
+/// )
+/// ```
+#[elem(scope, Show, LocalName, Figurable)]
+pub struct ImageElem {
+ /// Path to an image file
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ #[required]
+ #[parse(
+ let Spanned { v: path, span } =
+ args.expect::<Spanned<EcoString>>("path to image file")?;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ path
+ )]
+ #[borrowed]
+ pub path: EcoString,
+
+ /// The raw file data.
+ #[internal]
+ #[required]
+ #[parse(Readable::Bytes(data))]
+ pub data: Readable,
+
+ /// The image's format. Detected automatically by default.
+ ///
+ /// Supported formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image
+ /// is [not currently supported](https://github.com/typst/typst/issues/145).
+ pub format: Smart<ImageFormat>,
+
+ /// The width of the image.
+ pub width: Smart<Rel<Length>>,
+
+ /// The height of the image.
+ pub height: Sizing,
+
+ /// A text describing the image.
+ pub alt: Option<EcoString>,
+
+ /// How the image should adjust itself to a given area (the area is defined
+ /// by the `width` and `height` fields). Note that `fit` doesn't visually
+ /// change anything if the area's aspect ratio is the same as the image's
+ /// one.
+ ///
+ /// ```example
+ /// #set page(width: 300pt, height: 50pt, margin: 10pt)
+ /// #image("tiger.jpg", width: 100%, fit: "cover")
+ /// #image("tiger.jpg", width: 100%, fit: "contain")
+ /// #image("tiger.jpg", width: 100%, fit: "stretch")
+ /// ```
+ #[default(ImageFit::Cover)]
+ pub fit: ImageFit,
+}
+
+#[scope]
+impl ImageElem {
+ /// Decode a raster or vector graphic from bytes or a string.
+ ///
+ /// ```example
+ /// #let original = read("diagram.svg")
+ /// #let changed = original.replace(
+ /// "#2B80FF", // blue
+ /// green.to-hex(),
+ /// )
+ ///
+ /// #image.decode(original)
+ /// #image.decode(changed)
+ /// ```
+ #[func(title = "Decode Image")]
+ pub fn decode(
+ /// The call span of this function.
+ span: Span,
+ /// The data to decode as an image. Can be a string for SVGs.
+ data: Readable,
+ /// The image's format. Detected automatically by default.
+ #[named]
+ format: Option<Smart<ImageFormat>>,
+ /// The width of the image.
+ #[named]
+ width: Option<Smart<Rel<Length>>>,
+ /// The height of the image.
+ #[named]
+ height: Option<Sizing>,
+ /// A text describing the image.
+ #[named]
+ alt: Option<Option<EcoString>>,
+ /// How the image should adjust itself to a given area.
+ #[named]
+ fit: Option<ImageFit>,
+ ) -> StrResult<Content> {
+ let mut elem = ImageElem::new(EcoString::new(), data);
+ if let Some(format) = format {
+ elem.push_format(format);
+ }
+ if let Some(width) = width {
+ elem.push_width(width);
+ }
+ if let Some(height) = height {
+ elem.push_height(height);
+ }
+ if let Some(alt) = alt {
+ elem.push_alt(alt);
+ }
+ if let Some(fit) = fit {
+ elem.push_fit(fit);
+ }
+ Ok(elem.pack().spanned(span))
+ }
+}
+
+impl Show for Packed<ImageElem> {
+ fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_image)
+ .with_width(self.width(styles))
+ .with_height(self.height(styles))
+ .pack()
+ .spanned(self.span()))
+ }
+}
+
+impl LocalName for Packed<ImageElem> {
+ const KEY: &'static str = "figure";
+}
+
+impl Figurable for Packed<ImageElem> {}
+
+/// How an image should adjust itself to a given area,
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum ImageFit {
+ /// The image should completely cover the area (preserves aspect ratio by
+ /// cropping the image only horizontally or vertically). This is the
+ /// default.
+ Cover,
+ /// The image should be fully contained in the area (preserves aspect
+ /// ratio; doesn't crop the image; one dimension can be narrower than
+ /// specified).
+ Contain,
+ /// The image should be stretched so that it exactly fills the area, even if
+ /// this means that the image will be distorted (doesn't preserve aspect
+ /// ratio and doesn't crop the image).
+ Stretch,
+}
+
+/// A loaded raster or vector image.
+///
+/// Values of this type are cheap to clone and hash.
+#[derive(Clone, Hash, Eq, PartialEq)]
+pub struct Image(Arc<LazyHash<Repr>>);
+
+/// The internal representation.
+#[derive(Hash)]
+struct Repr {
+ /// The raw, undecoded image data.
+ kind: ImageKind,
+ /// A text describing the image.
+ alt: Option<EcoString>,
+}
+
+/// A kind of image.
+#[derive(Hash)]
+pub enum ImageKind {
+ /// A raster image.
+ Raster(RasterImage),
+ /// An SVG image.
+ Svg(SvgImage),
+}
+
+impl Image {
+ /// When scaling an image to it's natural size, we default to this DPI
+ /// if the image doesn't contain DPI metadata.
+ pub const DEFAULT_DPI: f64 = 72.0;
+
+ /// Should always be the same as the default DPI used by usvg.
+ pub const USVG_DEFAULT_DPI: f64 = 96.0;
+
+ /// Create an image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn new(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::new(data)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// Create a possibly font-dependent image from a buffer and a format.
+ #[comemo::memoize]
+ #[typst_macros::time(name = "load image")]
+ pub fn with_fonts(
+ data: Bytes,
+ format: ImageFormat,
+ alt: Option<EcoString>,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<Image> {
+ let kind = match format {
+ ImageFormat::Raster(format) => {
+ ImageKind::Raster(RasterImage::new(data, format)?)
+ }
+ ImageFormat::Vector(VectorFormat::Svg) => {
+ ImageKind::Svg(SvgImage::with_fonts(data, world, families)?)
+ }
+ };
+
+ Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt }))))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.data(),
+ ImageKind::Svg(svg) => svg.data(),
+ }
+ }
+
+ /// The format of the image.
+ pub fn format(&self) -> ImageFormat {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.format().into(),
+ ImageKind::Svg(_) => VectorFormat::Svg.into(),
+ }
+ }
+
+ /// The width of the image in pixels.
+ pub fn width(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.width() as f64,
+ ImageKind::Svg(svg) => svg.width(),
+ }
+ }
+
+ /// The height of the image in pixels.
+ pub fn height(&self) -> f64 {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.height() as f64,
+ ImageKind::Svg(svg) => svg.height(),
+ }
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ match &self.0.kind {
+ ImageKind::Raster(raster) => raster.dpi(),
+ ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI),
+ }
+ }
+
+ /// A text describing the image.
+ pub fn alt(&self) -> Option<&str> {
+ self.0.alt.as_deref()
+ }
+
+ /// The decoded image.
+ pub fn kind(&self) -> &ImageKind {
+ &self.0.kind
+ }
+}
+
+impl Debug for Image {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ f.debug_struct("Image")
+ .field("format", &self.format())
+ .field("width", &self.width())
+ .field("height", &self.height())
+ .field("alt", &self.alt())
+ .finish()
+ }
+}
+
+/// A raster or vector image format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ImageFormat {
+ /// A raster graphics format.
+ Raster(RasterFormat),
+ /// A vector graphics format.
+ Vector(VectorFormat),
+}
+
+/// A vector graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum VectorFormat {
+ /// The vector graphics format of the web.
+ Svg,
+}
+
+impl From<RasterFormat> for ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ Self::Raster(format)
+ }
+}
+
+impl From<VectorFormat> for ImageFormat {
+ fn from(format: VectorFormat) -> Self {
+ Self::Vector(format)
+ }
+}
+
+cast! {
+ ImageFormat,
+ self => match self {
+ Self::Raster(v) => v.into_value(),
+ Self::Vector(v) => v.into_value()
+ },
+ v: RasterFormat => Self::Raster(v),
+ v: VectorFormat => Self::Vector(v),
+}
diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs
new file mode 100644
index 00000000..829826c7
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/raster.rs
@@ -0,0 +1,286 @@
+use std::cmp::Ordering;
+use std::hash::{Hash, Hasher};
+use std::io;
+use std::sync::Arc;
+
+use ecow::{eco_format, EcoString};
+use image::codecs::gif::GifDecoder;
+use image::codecs::jpeg::JpegDecoder;
+use image::codecs::png::PngDecoder;
+use image::{guess_format, DynamicImage, ImageDecoder, ImageResult, Limits};
+
+use crate::diag::{bail, StrResult};
+use crate::foundations::{Bytes, Cast};
+
+/// A decoded raster image.
+#[derive(Clone, Hash)]
+pub struct RasterImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ format: RasterFormat,
+ dynamic: image::DynamicImage,
+ icc: Option<Vec<u8>>,
+ dpi: Option<f64>,
+}
+
+impl RasterImage {
+ /// Decode a raster image.
+ #[comemo::memoize]
+ pub fn new(data: Bytes, format: RasterFormat) -> StrResult<RasterImage> {
+ fn decode_with<T: ImageDecoder>(
+ decoder: ImageResult<T>,
+ ) -> ImageResult<(image::DynamicImage, Option<Vec<u8>>)> {
+ let mut decoder = decoder?;
+ let icc = decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty());
+ decoder.set_limits(Limits::default())?;
+ let dynamic = image::DynamicImage::from_decoder(decoder)?;
+ Ok((dynamic, icc))
+ }
+
+ let cursor = io::Cursor::new(&data);
+ let (mut dynamic, icc) = match format {
+ RasterFormat::Jpg => decode_with(JpegDecoder::new(cursor)),
+ RasterFormat::Png => decode_with(PngDecoder::new(cursor)),
+ RasterFormat::Gif => decode_with(GifDecoder::new(cursor)),
+ }
+ .map_err(format_image_error)?;
+
+ let exif = exif::Reader::new()
+ .read_from_container(&mut std::io::Cursor::new(&data))
+ .ok();
+
+ // Apply rotation from EXIF metadata.
+ if let Some(rotation) = exif.as_ref().and_then(exif_rotation) {
+ apply_rotation(&mut dynamic, rotation);
+ }
+
+ // Extract pixel density.
+ let dpi = determine_dpi(&data, exif.as_ref());
+
+ Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The image's format.
+ pub fn format(&self) -> RasterFormat {
+ self.0.format
+ }
+
+ /// The image's pixel width.
+ pub fn width(&self) -> u32 {
+ self.dynamic().width()
+ }
+
+ /// The image's pixel height.
+ pub fn height(&self) -> u32 {
+ self.dynamic().height()
+ }
+
+ /// The image's pixel density in pixels per inch, if known.
+ pub fn dpi(&self) -> Option<f64> {
+ self.0.dpi
+ }
+
+ /// Access the underlying dynamic image.
+ pub fn dynamic(&self) -> &image::DynamicImage {
+ &self.0.dynamic
+ }
+
+ /// Access the ICC profile, if any.
+ pub fn icc(&self) -> Option<&[u8]> {
+ self.0.icc.as_deref()
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // The image is fully defined by data and format.
+ self.data.hash(state);
+ self.format.hash(state);
+ }
+}
+
+/// A raster graphics format.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum RasterFormat {
+ /// Raster format for illustrations and transparent graphics.
+ Png,
+ /// Lossy raster format suitable for photos.
+ Jpg,
+ /// Raster format that is typically used for short animated clips.
+ Gif,
+}
+
+impl RasterFormat {
+ /// Try to detect the format of data in a buffer.
+ pub fn detect(data: &[u8]) -> Option<Self> {
+ guess_format(data).ok().and_then(|format| format.try_into().ok())
+ }
+}
+
+impl From<RasterFormat> for image::ImageFormat {
+ fn from(format: RasterFormat) -> Self {
+ match format {
+ RasterFormat::Png => image::ImageFormat::Png,
+ RasterFormat::Jpg => image::ImageFormat::Jpeg,
+ RasterFormat::Gif => image::ImageFormat::Gif,
+ }
+ }
+}
+
+impl TryFrom<image::ImageFormat> for RasterFormat {
+ type Error = EcoString;
+
+ fn try_from(format: image::ImageFormat) -> StrResult<Self> {
+ Ok(match format {
+ image::ImageFormat::Png => RasterFormat::Png,
+ image::ImageFormat::Jpeg => RasterFormat::Jpg,
+ image::ImageFormat::Gif => RasterFormat::Gif,
+ _ => bail!("Format not yet supported."),
+ })
+ }
+}
+
+/// Try to get the rotation from the EXIF metadata.
+fn exif_rotation(exif: &exif::Exif) -> Option<u32> {
+ exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?
+ .value
+ .get_uint(0)
+}
+
+/// Apply an EXIF rotation to a dynamic image.
+fn apply_rotation(image: &mut DynamicImage, rotation: u32) {
+ use image::imageops as ops;
+ match rotation {
+ 2 => ops::flip_horizontal_in_place(image),
+ 3 => ops::rotate180_in_place(image),
+ 4 => ops::flip_vertical_in_place(image),
+ 5 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate270();
+ }
+ 6 => *image = image.rotate90(),
+ 7 => {
+ ops::flip_horizontal_in_place(image);
+ *image = image.rotate90();
+ }
+ 8 => *image = image.rotate270(),
+ _ => {}
+ }
+}
+
+/// Try to determine the DPI (dots per inch) of the image.
+fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option<f64> {
+ // Try to extract the DPI from the EXIF metadata. If that doesn't yield
+ // anything, fall back to specialized procedures for extracting JPEG or PNG
+ // DPI metadata. GIF does not have any.
+ exif.and_then(exif_dpi)
+ .or_else(|| jpeg_dpi(data))
+ .or_else(|| png_dpi(data))
+}
+
+/// Try to get the DPI from the EXIF metadata.
+fn exif_dpi(exif: &exif::Exif) -> Option<f64> {
+ let axis = |tag| {
+ let dpi = exif.get_field(tag, exif::In::PRIMARY)?;
+ let exif::Value::Rational(rational) = &dpi.value else { return None };
+ Some(rational.first()?.to_f64())
+ };
+
+ [axis(exif::Tag::XResolution), axis(exif::Tag::YResolution)]
+ .into_iter()
+ .flatten()
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
+}
+
+/// Tries to extract the DPI from raw JPEG data (by inspecting the JFIF APP0
+/// section).
+fn jpeg_dpi(data: &[u8]) -> Option<f64> {
+ let validate_at = |index: usize, expect: &[u8]| -> Option<()> {
+ data.get(index..)?.starts_with(expect).then_some(())
+ };
+ let u16_at = |index: usize| -> Option<u16> {
+ data.get(index..index + 2)?.try_into().ok().map(u16::from_be_bytes)
+ };
+
+ validate_at(0, b"\xFF\xD8\xFF\xE0\0")?;
+ validate_at(6, b"JFIF\0")?;
+ validate_at(11, b"\x01")?;
+
+ let len = u16_at(4)?;
+ if len < 16 {
+ return None;
+ }
+
+ let units = *data.get(13)?;
+ let x = u16_at(14)?;
+ let y = u16_at(16)?;
+ let dpu = x.max(y) as f64;
+
+ Some(match units {
+ 1 => dpu, // already inches
+ 2 => dpu * 2.54, // cm -> inches
+ _ => return None,
+ })
+}
+
+/// Tries to extract the DPI from raw PNG data.
+fn png_dpi(mut data: &[u8]) -> Option<f64> {
+ let mut decoder = png::StreamingDecoder::new();
+ let dims = loop {
+ let (consumed, event) = decoder.update(data, &mut Vec::new()).ok()?;
+ match event {
+ png::Decoded::PixelDimensions(dims) => break dims,
+ // Bail as soon as there is anything data-like.
+ png::Decoded::ChunkBegin(_, png::chunk::IDAT)
+ | png::Decoded::ImageData
+ | png::Decoded::ImageEnd => return None,
+ _ => {}
+ }
+ data = data.get(consumed..)?;
+ if consumed == 0 {
+ return None;
+ }
+ };
+
+ let dpu = dims.xppu.max(dims.yppu) as f64;
+ match dims.unit {
+ png::Unit::Meter => Some(dpu * 0.0254), // meter -> inches
+ png::Unit::Unspecified => None,
+ }
+}
+
+/// Format the user-facing raster graphic decoding error message.
+fn format_image_error(error: image::ImageError) -> EcoString {
+ match error {
+ image::ImageError::Limits(_) => "file is too large".into(),
+ err => eco_format!("failed to decode image ({err})"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RasterFormat, RasterImage};
+ use crate::foundations::Bytes;
+
+ #[test]
+ fn test_image_dpi() {
+ #[track_caller]
+ fn test(path: &str, format: RasterFormat, dpi: f64) {
+ let data = typst_dev_assets::get(path).unwrap();
+ let bytes = Bytes::from_static(data);
+ let image = RasterImage::new(bytes, format).unwrap();
+ assert_eq!(image.dpi().map(f64::round), Some(dpi));
+ }
+
+ test("images/f2t.jpg", RasterFormat::Jpg, 220.0);
+ test("images/tiger.jpg", RasterFormat::Jpg, 72.0);
+ test("images/graph.png", RasterFormat::Png, 144.0);
+ }
+}
diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs
new file mode 100644
index 00000000..f7a498a8
--- /dev/null
+++ b/crates/typst-library/src/visualize/image/svg.rs
@@ -0,0 +1,289 @@
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+use std::sync::{Arc, Mutex};
+
+use comemo::Tracked;
+use ecow::EcoString;
+use siphasher::sip128::{Hasher128, SipHasher13};
+
+use crate::diag::{format_xml_like_error, StrResult};
+use crate::foundations::Bytes;
+use crate::layout::Axes;
+use crate::text::{
+ Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
+};
+use crate::World;
+
+/// A decoded SVG.
+#[derive(Clone, Hash)]
+pub struct SvgImage(Arc<Repr>);
+
+/// The internal representation.
+struct Repr {
+ data: Bytes,
+ size: Axes<f64>,
+ font_hash: u128,
+ tree: usvg::Tree,
+}
+
+impl SvgImage {
+ /// Decode an SVG image without fonts.
+ #[comemo::memoize]
+ pub fn new(data: Bytes) -> StrResult<SvgImage> {
+ let tree =
+ usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?;
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash: 0, tree })))
+ }
+
+ /// Decode an SVG image with access to fonts.
+ #[comemo::memoize]
+ pub fn with_fonts(
+ data: Bytes,
+ world: Tracked<dyn World + '_>,
+ families: &[&str],
+ ) -> StrResult<SvgImage> {
+ let book = world.book();
+ let resolver = Mutex::new(FontResolver::new(world, book, families));
+ let tree = usvg::Tree::from_data(
+ &data,
+ &usvg::Options {
+ font_resolver: usvg::FontResolver {
+ select_font: Box::new(|font, db| {
+ resolver.lock().unwrap().select_font(font, db)
+ }),
+ select_fallback: Box::new(|c, exclude_fonts, db| {
+ resolver.lock().unwrap().select_fallback(c, exclude_fonts, db)
+ }),
+ },
+ ..base_options()
+ },
+ )
+ .map_err(format_usvg_error)?;
+ let font_hash = resolver.into_inner().unwrap().finish();
+ Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash, tree })))
+ }
+
+ /// The raw image data.
+ pub fn data(&self) -> &Bytes {
+ &self.0.data
+ }
+
+ /// The SVG's width in pixels.
+ pub fn width(&self) -> f64 {
+ self.0.size.x
+ }
+
+ /// The SVG's height in pixels.
+ pub fn height(&self) -> f64 {
+ self.0.size.y
+ }
+
+ /// Accesses the usvg tree.
+ pub fn tree(&self) -> &usvg::Tree {
+ &self.0.tree
+ }
+}
+
+impl Hash for Repr {
+ fn hash<H: Hasher>(&self, state: &mut H) {
+ // An SVG might contain fonts, which must be incorporated into the hash.
+ // We can't hash a usvg tree directly, but the raw SVG data + a hash of
+ // all used fonts gives us something similar.
+ self.data.hash(state);
+ self.font_hash.hash(state);
+ }
+}
+
+/// The base conversion options, to be extended with font-related options
+/// because those can change across the document.
+fn base_options() -> usvg::Options<'static> {
+ usvg::Options {
+ // Disable usvg's default to "Times New Roman".
+ font_family: String::new(),
+
+ // We don't override the DPI here, because we already
+ // force the image into the corresponding DPI by setting
+ // the width and height. Changing the DPI only trips up
+ // the logic in `resvg`.
+
+ // Override usvg's resource loading defaults.
+ resources_dir: None,
+ image_href_resolver: usvg::ImageHrefResolver {
+ resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
+ resolve_string: Box::new(|_, _| None),
+ },
+
+ ..Default::default()
+ }
+}
+
+/// The pixel size of an SVG.
+fn tree_size(tree: &usvg::Tree) -> Axes<f64> {
+ Axes::new(tree.size().width() as f64, tree.size().height() as f64)
+}
+
+/// Format the user-facing SVG decoding error message.
+fn format_usvg_error(error: usvg::Error) -> EcoString {
+ match error {
+ usvg::Error::NotAnUtf8Str => "file is not valid utf-8".into(),
+ usvg::Error::MalformedGZip => "file is not compressed correctly".into(),
+ usvg::Error::ElementsLimitReached => "file is too large".into(),
+ usvg::Error::InvalidSize => {
+ "failed to parse SVG (width, height, or viewbox is invalid)".into()
+ }
+ usvg::Error::ParsingFailed(error) => format_xml_like_error("SVG", error),
+ }
+}
+
+/// Provides Typst's fonts to usvg.
+struct FontResolver<'a> {
+ /// Typst's font book.
+ book: &'a FontBook,
+ /// The world we use to load fonts.
+ world: Tracked<'a, dyn World + 'a>,
+ /// The active list of font families at the location of the SVG.
+ families: &'a [&'a str],
+ /// A mapping from Typst font indices to fontdb IDs.
+ to_id: HashMap<usize, Option<fontdb::ID>>,
+ /// The reverse mapping.
+ from_id: HashMap<fontdb::ID, Font>,
+ /// Accumulates a hash of all used fonts.
+ hasher: SipHasher13,
+}
+
+impl<'a> FontResolver<'a> {
+ /// Create a new font provider.
+ fn new(
+ world: Tracked<'a, dyn World + 'a>,
+ book: &'a FontBook,
+ families: &'a [&'a str],
+ ) -> Self {
+ Self {
+ book,
+ world,
+ families,
+ to_id: HashMap::new(),
+ from_id: HashMap::new(),
+ hasher: SipHasher13::new(),
+ }
+ }
+
+ /// Returns a hash of all used fonts.
+ fn finish(self) -> u128 {
+ self.hasher.finish128().as_u128()
+ }
+}
+
+impl FontResolver<'_> {
+ /// Select a font.
+ fn select_font(
+ &mut self,
+ font: &usvg::Font,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let variant = FontVariant {
+ style: font.style().into(),
+ weight: FontWeight::from_number(font.weight()),
+ stretch: font.stretch().into(),
+ };
+
+ // Find a family that is available.
+ font.families()
+ .iter()
+ .filter_map(|family| match family {
+ usvg::FontFamily::Named(named) => Some(named.as_str()),
+ // We don't support generic families at the moment.
+ _ => None,
+ })
+ .chain(self.families.iter().copied())
+ .filter_map(|named| self.book.select(&named.to_lowercase(), variant))
+ .find_map(|index| self.get_or_load(index, db))
+ }
+
+ /// Select a fallback font.
+ fn select_fallback(
+ &mut self,
+ c: char,
+ exclude_fonts: &[fontdb::ID],
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ // Get the font info of the originally selected font.
+ let like = exclude_fonts
+ .first()
+ .and_then(|first| self.from_id.get(first))
+ .map(|font| font.info());
+
+ // usvg doesn't provide a variant in the fallback handler, but
+ // `exclude_fonts` is actually never empty in practice. Still, we
+ // prefer to fall back to the default variant rather than panicking
+ // in case that changes in the future.
+ let variant = like.map(|info| info.variant).unwrap_or_default();
+
+ // Select the font.
+ let index =
+ self.book.select_fallback(like, variant, c.encode_utf8(&mut [0; 4]))?;
+
+ self.get_or_load(index, db)
+ }
+
+ /// Tries to retrieve the ID for the index or loads the font, allocating
+ /// a new ID.
+ fn get_or_load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ self.to_id
+ .get(&index)
+ .copied()
+ .unwrap_or_else(|| self.load(index, db))
+ }
+
+ /// Tries to load the font with the given index in the font book into the
+ /// database and returns its ID.
+ fn load(
+ &mut self,
+ index: usize,
+ db: &mut Arc<fontdb::Database>,
+ ) -> Option<fontdb::ID> {
+ let font = self.world.font(index)?;
+ let info = font.info();
+ let variant = info.variant;
+ let id = Arc::make_mut(db).push_face_info(fontdb::FaceInfo {
+ id: fontdb::ID::dummy(),
+ source: fontdb::Source::Binary(Arc::new(font.data().clone())),
+ index: font.index(),
+ families: vec![(
+ info.family.clone(),
+ ttf_parser::Language::English_UnitedStates,
+ )],
+ post_script_name: String::new(),
+ style: match variant.style {
+ FontStyle::Normal => fontdb::Style::Normal,
+ FontStyle::Italic => fontdb::Style::Italic,
+ FontStyle::Oblique => fontdb::Style::Oblique,
+ },
+ weight: fontdb::Weight(variant.weight.to_number()),
+ stretch: match variant.stretch.round() {
+ FontStretch::ULTRA_CONDENSED => ttf_parser::Width::UltraCondensed,
+ FontStretch::EXTRA_CONDENSED => ttf_parser::Width::ExtraCondensed,
+ FontStretch::CONDENSED => ttf_parser::Width::Condensed,
+ FontStretch::SEMI_CONDENSED => ttf_parser::Width::SemiCondensed,
+ FontStretch::NORMAL => ttf_parser::Width::Normal,
+ FontStretch::SEMI_EXPANDED => ttf_parser::Width::SemiExpanded,
+ FontStretch::EXPANDED => ttf_parser::Width::Expanded,
+ FontStretch::EXTRA_EXPANDED => ttf_parser::Width::ExtraExpanded,
+ FontStretch::ULTRA_EXPANDED => ttf_parser::Width::UltraExpanded,
+ _ => unreachable!(),
+ },
+ monospaced: info.flags.contains(FontFlags::MONOSPACE),
+ });
+
+ font.hash(&mut self.hasher);
+
+ self.to_id.insert(index, Some(id));
+ self.from_id.insert(id, font);
+
+ Some(id)
+ }
+}