diff options
| author | Laurenz <laurmaedje@gmail.com> | 2024-10-27 19:04:55 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-27 18:04:55 +0000 |
| commit | be7cfc85d08c545abfac08098b7b33b4bd71f37e (patch) | |
| tree | f4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-library/src/visualize/image | |
| parent | b8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff) | |
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-library/src/visualize/image')
| -rw-r--r-- | crates/typst-library/src/visualize/image/mod.rs | 360 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/image/raster.rs | 286 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/image/svg.rs | 289 |
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) + } +} |
