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 @@
+
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],
+)