Handle SVG with text

This commit is contained in:
Laurenz 2023-04-18 19:04:46 +02:00
parent bce83d330f
commit 35302d2004
12 changed files with 268 additions and 60 deletions

24
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

14
assets/files/diagram.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Binary file not shown.

View file

@ -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)
}

View file

@ -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();

View file

@ -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 =

View file

@ -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`

View file

@ -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,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<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 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());
}
}
});
// 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.
fn format_image_error(error: image::ImageError) -> EcoString {
match error {

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,9 @@
// Test SVG with text.
---
#set page(width: 250pt)
#figure(
image("/diagram.svg"),
caption: [A textful diagram],
)