summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2023-04-18 19:04:46 +0200
committerLaurenz <laurmaedje@gmail.com>2023-04-18 19:04:46 +0200
commit35302d20047c58baa99065a4574eafe1920e7be6 (patch)
treeb9c7dbf3e15c24cc063da70dbc2fa3d5befa41d4
parentbce83d330f0efc0f8cdd6fb01d8244cb9f01ac27 (diff)
Handle SVG with text
-rw-r--r--Cargo.lock24
-rw-r--r--Cargo.toml3
-rw-r--r--assets/files/diagram.svg14
-rw-r--r--assets/fonts/InriaSerif-Bold.ttfbin0 -> 95832 bytes
-rw-r--r--assets/fonts/InriaSerif-Italic.ttfbin0 -> 100352 bytes
-rw-r--r--library/src/visualize/image.rs21
-rw-r--r--src/export/pdf/image.rs2
-rw-r--r--src/export/render.rs2
-rw-r--r--src/font/book.rs19
-rw-r--r--src/image.rs234
-rw-r--r--tests/ref/visualize/svg-text.pngbin0 -> 14244 bytes
-rw-r--r--tests/typ/visualize/svg-text.typ9
12 files changed, 268 insertions, 60 deletions
diff --git a/Cargo.lock b/Cargo.lock
index b0bf355c..1f66916a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -547,6 +547,17 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1597,6 +1608,7 @@ dependencies = [
"comemo",
"ecow",
"flate2",
+ "fontdb",
"if_chain",
"image",
"indexmap",
@@ -1808,6 +1820,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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 ea4bde6b..9c437fd4 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 00000000..dc42e068
--- /dev/null
+++ b/assets/files/diagram.svg
@@ -0,0 +1,14 @@
+<svg width="550" height="356" viewBox="0 0 550 356" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="550" height="356" fill="white"/>
+<path d="M19.7071 18.2929C19.3166 17.9024 18.6834 17.9024 18.2929 18.2929L11.9289 24.6569C11.5384 25.0474 11.5384 25.6805 11.9289 26.0711C12.3194 26.4616 12.9526 26.4616 13.3431 26.0711L19 20.4142L24.6568 26.0711C25.0474 26.4616 25.6805 26.4616 26.0711 26.0711C26.4616 25.6805 26.4616 25.0474 26.0711 24.6569L19.7071 18.2929ZM20 336L20 19L18 19L18 336L20 336Z" fill="black"/>
+<path d="M525.707 336.707C526.098 336.317 526.098 335.683 525.707 335.293L519.343 328.929C518.953 328.538 518.319 328.538 517.929 328.929C517.538 329.319 517.538 329.953 517.929 330.343L523.586 336L517.929 341.657C517.538 342.047 517.538 342.681 517.929 343.071C518.319 343.462 518.953 343.462 519.343 343.071L525.707 336.707ZM19 337H525V335H19V337Z" fill="black"/>
+<text fill="black" font-family="Stupid, Inria Serif" font-size="24" letter-spacing="0em"><tspan x="34.0469" y="43.9274">Height</tspan></text>
+<text fill="black" font-family="Stupid, Inria Serif" font-size="24" font-style="italic" letter-spacing="0em"><tspan x="34.0469" y="72.9274">Height</tspan></text>
+<text fill="black" font-family="Stupid, Inria Serif" font-size="24" font-weight="bold" letter-spacing="0em"><tspan x="34.0469" y="101.927">Height</tspan></text>
+<text fill="black" font-family="Stupid, Inria Serif" font-size="24" font-style="italic" font-weight="bold" letter-spacing="0em"><tspan x="34.0469" y="130.927">Height</tspan></text>
+<text fill="black" font-size="22" font-weight="bold" letter-spacing="0em"><tspan x="99.0469" y="278.783">Without family</tspan></text>
+<text fill="black" font-family="Inter" font-size="22" font-style="italic" letter-spacing="0em"><tspan x="58.0469" y="315">With non-existing family</tspan></text>
+<text fill="black" font-family="Roboto" font-size="24" letter-spacing="0em" text-decoration="underline"><tspan x="466" y="310.703">Time</tspan></text>
+<path d="M20 335C20 335 59.8833 265.479 102 241C143.386 216.945 162.368 211.763 210 207C270 201 321.161 208.851 374 178C398.284 163.821 431 134 431 134L518 65" stroke="#2B80FF" stroke-width="2"/>
+<text transform="translate(428.859 89.5114) rotate(-38.8045)" fill="#2B80FF" xml:space="preserve" style="white-space: pre" font-family="DejaVu Sans Mono" font-size="24" font-weight="bold" letter-spacing="0em"><tspan x="0" y="22.3086">Curve</tspan></text>
+</svg>
diff --git a/assets/fonts/InriaSerif-Bold.ttf b/assets/fonts/InriaSerif-Bold.ttf
new file mode 100644
index 00000000..d0874eac
--- /dev/null
+++ b/assets/fonts/InriaSerif-Bold.ttf
Binary files differ
diff --git a/assets/fonts/InriaSerif-Italic.ttf b/assets/fonts/InriaSerif-Italic.ttf
new file mode 100644
index 00000000..b1bc8d4a
--- /dev/null
+++ b/assets/fonts/InriaSerif-Italic.ttf
Binary files differ
diff --git a/library/src/visualize/image.rs b/library/src/visualize/image.rs
index 473df4c1..4b8be5c7 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::<Spanned<EcoString>>("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<Fragment> {
- 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<dyn World>, full: &str) -> StrResult<Image> {
+fn load(
+ world: Tracked<dyn World>,
+ full: &str,
+ fallback_family: Option<&str>,
+) -> StrResult<Image> {
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<dyn World>, full: &str) -> StrResult<Image> {
"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 906737de..e6eaa9c9 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 f3c72ba0..3c2cea8d 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<Arc<sk::Pixmap>> {
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 a6e41860..5af2ccb2 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<usize> {
@@ -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<Item = usize> + '_ {
+ 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 23ea60f5..09aaf24d 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<Repr>);
+
+/// 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<Self> {
- 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<dyn World>,
+ fallback_family: Option<&str>,
+ ) -> StrResult<Self> {
+ 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<Arc<DecodedImage>> {
- 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<H: Hasher>(&self, state: &mut H) {
+ self.data().hash(state);
+ self.format().hash(state);
}
}
@@ -131,26 +163,134 @@ pub enum DecodedImage {
Svg(usvg::Tree),
}
-/// Determine the image size in pixels.
+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,
+ }
+ }
+
+ /// 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<Image> {
+ 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 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)?)
+fn decode_svg(data: Buffer) -> StrResult<Image> {
+ 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<dyn World>,
+ fallback_family: Option<&str>,
+) -> StrResult<Image> {
+ // 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<dyn World>,
+ 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::<EcoString>::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());
+ }
}
- 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))
+ });
+
+ // 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<F>(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.
diff --git a/tests/ref/visualize/svg-text.png b/tests/ref/visualize/svg-text.png
new file mode 100644
index 00000000..fbaa8576
--- /dev/null
+++ b/tests/ref/visualize/svg-text.png
Binary files differ
diff --git a/tests/typ/visualize/svg-text.typ b/tests/typ/visualize/svg-text.typ
new file mode 100644
index 00000000..132905a8
--- /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],
+)