diff --git a/Cargo.lock b/Cargo.lock index b0bf355c4..1f66916a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -546,6 +546,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fontdb" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52186a39c335aa6f79fc0bf1c3cf854870b6ad4e50a7bb8a59b4ba1331f478a" +dependencies = [ + "log", + "memmap2", + "ttf-parser 0.17.1", +] + [[package]] name = "form_urlencoded" version = "1.1.0" @@ -1597,6 +1608,7 @@ dependencies = [ "comemo", "ecow", "flate2", + "fontdb", "if_chain", "image", "indexmap", @@ -1807,6 +1819,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.10" @@ -1852,14 +1870,20 @@ dependencies = [ "data-url", "flate2", "float-cmp", + "fontdb", "kurbo", "log", "pico-args", "rctree", "roxmltree", + "rustybuzz", "simplecss", "siphasher", "svgtypes", + "ttf-parser 0.15.2", + "unicode-bidi", + "unicode-script", + "unicode-vo", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ea4bde6ba..9c437fd46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ bytemuck = "1" comemo = "0.2.2" ecow = "0.1" flate2 = "1" +fontdb = "0.9" if_chain = "1" image = { version = "0.24", default-features = false, features = ["png", "jpeg", "gif"] } log = "0.4" @@ -48,7 +49,7 @@ unicode-math-class = "0.1" unicode-segmentation = "1" unicode-xid = "0.2" unscanny = "0.1" -usvg = { version = "0.22", default-features = false } +usvg = { version = "0.22", default-features = false, features = ["text"] } xmp-writer = "0.1" indexmap = "1.9.3" diff --git a/assets/files/diagram.svg b/assets/files/diagram.svg new file mode 100644 index 000000000..dc42e068d --- /dev/null +++ b/assets/files/diagram.svg @@ -0,0 +1,14 @@ + + + + +Height +Height +Height +Height +Without family +With non-existing family +Time + +Curve + diff --git a/assets/fonts/InriaSerif-Bold.ttf b/assets/fonts/InriaSerif-Bold.ttf new file mode 100644 index 000000000..d0874eacf Binary files /dev/null and b/assets/fonts/InriaSerif-Bold.ttf differ diff --git a/assets/fonts/InriaSerif-Italic.ttf b/assets/fonts/InriaSerif-Italic.ttf new file mode 100644 index 000000000..b1bc8d4af Binary files /dev/null and b/assets/fonts/InriaSerif-Italic.ttf differ diff --git a/library/src/visualize/image.rs b/library/src/visualize/image.rs index 473df4c18..4b8be5c7f 100644 --- a/library/src/visualize/image.rs +++ b/library/src/visualize/image.rs @@ -3,10 +3,9 @@ use std::path::Path; use typst::image::{Image, ImageFormat, RasterFormat, VectorFormat}; -use crate::{ - meta::{Figurable, LocalName}, - prelude::*, -}; +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; +use crate::text::families; /// A raster or vector graphic. /// @@ -33,7 +32,7 @@ pub struct ImageElem { let Spanned { v: path, span } = args.expect::>("path to image file")?; let path: EcoString = vm.locate(&path).at(span)?.to_string_lossy().into(); - let _ = load(vm.world(), &path).at(span)?; + let _ = load(vm.world(), &path, None).at(span)?; path )] pub path: EcoString, @@ -56,7 +55,9 @@ impl Layout for ImageElem { styles: StyleChain, regions: Regions, ) -> SourceResult { - let image = load(vt.world, &self.path()).unwrap(); + let first = families(styles).next(); + let fallback_family = first.as_ref().map(|f| f.as_str()); + let image = load(vt.world, &self.path(), fallback_family).unwrap(); let sizing = Axes::new(self.width(styles), self.height(styles)); let region = sizing .zip(regions.base()) @@ -158,7 +159,11 @@ pub enum ImageFit { /// Load an image from a path. #[comemo::memoize] -fn load(world: Tracked, full: &str) -> StrResult { +fn load( + world: Tracked, + full: &str, + fallback_family: Option<&str>, +) -> StrResult { let full = Path::new(full); let buffer = world.file(full)?; let ext = full.extension().and_then(OsStr::to_str).unwrap_or_default(); @@ -169,5 +174,5 @@ fn load(world: Tracked, full: &str) -> StrResult { "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg), _ => return Err("unknown image format".into()), }; - Image::new(buffer, format) + Image::with_fonts(buffer, format, world, fallback_family) } diff --git a/src/export/pdf/image.rs b/src/export/pdf/image.rs index 906737dee..e6eaa9c99 100644 --- a/src/export/pdf/image.rs +++ b/src/export/pdf/image.rs @@ -17,7 +17,7 @@ pub fn write_images(ctx: &mut PdfContext) { // Add the primary image. // TODO: Error if image could not be encoded. - match image.decode().unwrap().as_ref() { + match image.decoded() { DecodedImage::Raster(dynamic, format) => { // TODO: Error if image could not be encoded. let (data, filter, has_color) = encode_image(*format, dynamic).unwrap(); diff --git a/src/export/render.rs b/src/export/render.rs index f3c72ba0f..3c2cea8dc 100644 --- a/src/export/render.rs +++ b/src/export/render.rs @@ -499,7 +499,7 @@ fn render_image( #[comemo::memoize] fn scaled_texture(image: &Image, w: u32, h: u32) -> Option> { let mut pixmap = sk::Pixmap::new(w, h)?; - match image.decode().unwrap().as_ref() { + match image.decoded() { DecodedImage::Raster(dynamic, _) => { let downscale = w < image.width(); let filter = diff --git a/src/font/book.rs b/src/font/book.rs index a6e41860f..5af2ccb2e 100644 --- a/src/font/book.rs +++ b/src/font/book.rs @@ -39,6 +39,11 @@ impl FontBook { self.infos.push(info); } + /// Get the font info for the given index. + pub fn info(&self, index: usize) -> Option<&FontInfo> { + self.infos.get(index) + } + /// An ordered iterator over all font families this book knows and details /// about the fonts that are part of them. pub fn families( @@ -53,8 +58,8 @@ impl FontBook { }) } - /// Try to find and load a font from the given `family` that matches - /// the given `variant` as closely as possible. + /// Try to find a font from the given `family` that matches the given + /// `variant` as closely as possible. /// /// The `family` should be all lowercase. pub fn select(&self, family: &str, variant: FontVariant) -> Option { @@ -62,6 +67,16 @@ impl FontBook { self.find_best_variant(None, variant, ids.iter().copied()) } + /// Iterate over all variants of a family. + pub fn select_family(&self, family: &str) -> impl Iterator + '_ { + self.families + .get(family) + .map(|vec| vec.as_slice()) + .unwrap_or_default() + .iter() + .copied() + } + /// Try to find and load a fallback font that /// - is as close as possible to the font `like` (if any) /// - is as close as possible to the given `variant` diff --git a/src/image.rs b/src/image.rs index 23ea60f58..09aaf24dc 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,74 +1,106 @@ //! Image handling. +use std::collections::BTreeSet; +use std::fmt::{self, Debug, Formatter}; +use std::hash::{Hash, Hasher}; use std::io; use std::sync::Arc; +use comemo::Tracked; use ecow::EcoString; use crate::diag::{format_xml_like_error, StrResult}; use crate::util::Buffer; +use crate::World; /// A raster or vector image. /// /// Values of this type are cheap to clone and hash. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct Image { +#[derive(Clone)] +pub struct Image(Arc); + +/// The internal representation. +struct Repr { /// The raw, undecoded image data. data: Buffer, /// The format of the encoded `buffer`. format: ImageFormat, - /// The width in pixels. - width: u32, - /// The height in pixels. - height: u32, + /// The decoded image. + decoded: DecodedImage, } impl Image { /// Create an image from a buffer and a format. - /// - /// Extracts the width and height. pub fn new(data: Buffer, format: ImageFormat) -> StrResult { - let (width, height) = determine_size(&data, format)?; - Ok(Self { data, format, width, height }) + match format { + ImageFormat::Raster(format) => decode_raster(data, format), + ImageFormat::Vector(VectorFormat::Svg) => decode_svg(data), + } + } + + /// Create a font-dependant image from a buffer and a format. + pub fn with_fonts( + data: Buffer, + format: ImageFormat, + world: Tracked, + fallback_family: Option<&str>, + ) -> StrResult { + match format { + ImageFormat::Raster(format) => decode_raster(data, format), + ImageFormat::Vector(VectorFormat::Svg) => { + decode_svg_with_fonts(data, world, fallback_family) + } + } } /// The raw image data. pub fn data(&self) -> &Buffer { - &self.data + &self.0.data } /// The format of the image. pub fn format(&self) -> ImageFormat { - self.format + self.0.format + } + + /// The decoded version of the image. + pub fn decoded(&self) -> &DecodedImage { + &self.0.decoded } /// The width of the image in pixels. pub fn width(&self) -> u32 { - self.width + self.decoded().width() } /// The height of the image in pixels. pub fn height(&self) -> u32 { - self.height + self.decoded().height() } +} - /// Decode the image. - #[comemo::memoize] - pub fn decode(&self) -> StrResult> { - Ok(Arc::new(match self.format { - ImageFormat::Vector(VectorFormat::Svg) => { - let opts = usvg::Options::default(); - let tree = usvg::Tree::from_data(&self.data, &opts.to_ref()) - .map_err(format_usvg_error)?; - DecodedImage::Svg(tree) - } - ImageFormat::Raster(format) => { - let cursor = io::Cursor::new(&self.data); - let reader = image::io::Reader::with_format(cursor, format.into()); - let dynamic = reader.decode().map_err(format_image_error)?; - DecodedImage::Raster(dynamic, format) - } - })) +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()) + .finish() + } +} + +impl Eq for Image {} + +impl PartialEq for Image { + fn eq(&self, other: &Self) -> bool { + self.data() == other.data() && self.format() == other.format() + } +} + +impl Hash for Image { + fn hash(&self, state: &mut H) { + self.data().hash(state); + self.format().hash(state); } } @@ -131,28 +163,136 @@ pub enum DecodedImage { Svg(usvg::Tree), } -/// Determine the image size in pixels. -#[comemo::memoize] -fn determine_size(data: &Buffer, format: ImageFormat) -> StrResult<(u32, u32)> { - match format { - ImageFormat::Raster(format) => { - let cursor = io::Cursor::new(&data); - let reader = image::io::Reader::with_format(cursor, format.into()); - Ok(reader.into_dimensions().map_err(format_image_error)?) +impl DecodedImage { + /// The width of the image in pixels. + pub fn width(&self) -> u32 { + match self { + Self::Raster(dynamic, _) => dynamic.width(), + Self::Svg(tree) => tree.svg_node().size.width().ceil() as u32, } - ImageFormat::Vector(VectorFormat::Svg) => { - let opts = usvg::Options::default(); - let tree = - usvg::Tree::from_data(data, &opts.to_ref()).map_err(format_usvg_error)?; + } - let size = tree.svg_node().size; - let width = size.width().ceil() as u32; - let height = size.height().ceil() as u32; - Ok((width, height)) + /// The height of the image in pixels. + pub fn height(&self) -> u32 { + match self { + Self::Raster(dynamic, _) => dynamic.height(), + Self::Svg(tree) => tree.svg_node().size.height().ceil() as u32, } } } +/// Decode a raster image. +#[comemo::memoize] +fn decode_raster(data: Buffer, format: RasterFormat) -> StrResult { + let cursor = io::Cursor::new(&data); + let reader = image::io::Reader::with_format(cursor, format.into()); + let dynamic = reader.decode().map_err(format_image_error)?; + Ok(Image(Arc::new(Repr { + data, + format: ImageFormat::Raster(format), + decoded: DecodedImage::Raster(dynamic, format), + }))) +} + +/// Decode an SVG image. +#[comemo::memoize] +fn decode_svg(data: Buffer) -> StrResult { + let opts = usvg::Options::default(); + let tree = usvg::Tree::from_data(&data, &opts.to_ref()).map_err(format_usvg_error)?; + Ok(Image(Arc::new(Repr { + data, + format: ImageFormat::Vector(VectorFormat::Svg), + decoded: DecodedImage::Svg(tree), + }))) +} + +/// Decode an SVG image with access to fonts. +#[comemo::memoize] +fn decode_svg_with_fonts( + data: Buffer, + world: Tracked, + fallback_family: Option<&str>, +) -> StrResult { + // Parse XML. + let xml = std::str::from_utf8(&data) + .map_err(|_| format_usvg_error(usvg::Error::NotAnUtf8Str))?; + let document = roxmltree::Document::parse(xml) + .map_err(|err| format_xml_like_error("svg", err))?; + + // Parse SVG. + let mut opts = usvg::Options { + fontdb: load_svg_fonts(&document, world, fallback_family), + ..Default::default() + }; + + // Recover the non-lowercased version of the family because + // usvg is case sensitive. + let book = world.book(); + if let Some(family) = fallback_family + .and_then(|lowercase| book.select_family(lowercase).next()) + .and_then(|index| book.info(index)) + .map(|info| info.family.clone()) + { + opts.font_family = family; + } + + let tree = + usvg::Tree::from_xmltree(&document, &opts.to_ref()).map_err(format_usvg_error)?; + + Ok(Image(Arc::new(Repr { + data, + format: ImageFormat::Vector(VectorFormat::Svg), + decoded: DecodedImage::Svg(tree), + }))) +} + +/// Discover and load the fonts referenced by an SVG. +fn load_svg_fonts( + document: &roxmltree::Document, + world: Tracked, + fallback_family: Option<&str>, +) -> fontdb::Database { + // Find out which font families are referenced by the SVG. We simply do a + // search for `font-family` attributes. This won't help with CSS, but usvg + // 22.0 doesn't seem to support it anyway. Once we bump to the latest usvg, + // this can be replaced by a scan for text elements in the SVG: + // https://github.com/RazrFalcon/resvg/issues/555 + let mut referenced = BTreeSet::::new(); + traverse_xml(&document.root(), &mut |node| { + if let Some(list) = node.attribute("font-family") { + for family in list.split(',') { + referenced.insert(EcoString::from(family.trim()).to_lowercase()); + } + } + }); + + // Prepare font database. + let mut fontdb = fontdb::Database::new(); + for family in referenced.iter().map(|family| family.as_str()).chain(fallback_family) { + // We load all variants for the family, since we don't know which will + // be used. + for id in world.book().select_family(family) { + if let Some(font) = world.font(id) { + let source = Arc::new(font.data().clone()); + fontdb.load_font_source(fontdb::Source::Binary(source)); + } + } + } + + fontdb +} + +/// Search for all font families referenced by an SVG. +fn traverse_xml(node: &roxmltree::Node, f: &mut F) +where + F: FnMut(&roxmltree::Node), +{ + f(node); + for child in node.children() { + traverse_xml(&child, f); + } +} + /// Format the user-facing raster graphic decoding error message. fn format_image_error(error: image::ImageError) -> EcoString { match error { diff --git a/tests/ref/visualize/svg-text.png b/tests/ref/visualize/svg-text.png new file mode 100644 index 000000000..fbaa85766 Binary files /dev/null and b/tests/ref/visualize/svg-text.png differ diff --git a/tests/typ/visualize/svg-text.typ b/tests/typ/visualize/svg-text.typ new file mode 100644 index 000000000..132905a88 --- /dev/null +++ b/tests/typ/visualize/svg-text.typ @@ -0,0 +1,9 @@ +// Test SVG with text. + +--- +#set page(width: 250pt) + +#figure( + image("/diagram.svg"), + caption: [A textful diagram], +)