Load linked bitmap images in SVG images (#6794)

Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
Toon Verstraelen 2025-09-02 16:17:48 +02:00 committed by GitHub
parent a880ac8bbf
commit 560e49b67c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 275 additions and 33 deletions

2
Cargo.lock generated
View File

@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "typst-dev-assets"
version = "0.13.1"
source = "git+https://github.com/typst/typst-dev-assets?rev=cb896b4#cb896b4ce786b8b33af15791da894327572fc488"
source = "git+https://github.com/typst/typst-dev-assets?rev=ed6b8b0#ed6b8b0e035097486c44a328bf331fad0bee96f6"
[[package]]
name = "typst-docs"

View File

@ -33,7 +33,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" }
typst-timing = { path = "crates/typst-timing", version = "0.13.1" }
typst-utils = { path = "crates/typst-utils", version = "0.13.1" }
typst-assets = { git = "https://github.com/typst/typst-assets", rev = "fbf00f9" }
typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "cb896b4" }
typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "ed6b8b0" }
arrayvec = "0.7.4"
az = "1.2"
base64 = "0.22"

View File

@ -593,7 +593,7 @@ pub type LoadResult<T> = Result<T, LoadError>;
/// [`FileId`]: typst_syntax::FileId
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct LoadError {
/// The position in the file at which the error occured.
/// The position in the file at which the error occurred.
pos: ReportPos,
/// Must contain a message formatted like this: `"failed to do thing (cause)"`.
message: EcoString,

View File

@ -265,14 +265,22 @@ impl Packed<ImageElem> {
)
.at(span)?,
),
ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg(
SvgImage::with_fonts(
loaded.data.clone(),
engine.world,
&families(styles).map(|f| f.as_str()).collect::<Vec<_>>(),
ImageFormat::Vector(VectorFormat::Svg) => {
// Identify the SVG file in case contained hrefs need to be resolved.
let svg_file = match self.source.source {
DataSource::Path(ref path) => span.resolve_path(path).ok(),
DataSource::Bytes(_) => span.id(),
};
ImageKind::Svg(
SvgImage::with_fonts_images(
loaded.data.clone(),
engine.world,
&families(styles).map(|f| f.as_str()).collect::<Vec<_>>(),
svg_file,
)
.within(loaded)?,
)
.within(loaded)?,
),
}
ImageFormat::Vector(VectorFormat::Pdf) => {
let document = match PdfDocument::new(loaded.data.clone()) {
Ok(doc) => doc,
@ -325,28 +333,37 @@ impl Packed<ImageElem> {
};
let Derived { source, derived: loaded } = &self.source;
if let DataSource::Path(path) = source {
let ext = std::path::Path::new(path.as_str())
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_lowercase();
match ext.as_str() {
"png" => return Ok(ExchangeFormat::Png.into()),
"jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()),
"gif" => return Ok(ExchangeFormat::Gif.into()),
"svg" | "svgz" => return Ok(VectorFormat::Svg.into()),
"pdf" => return Ok(VectorFormat::Pdf.into()),
"webp" => return Ok(ExchangeFormat::Webp.into()),
_ => {}
}
if let DataSource::Path(path) = source
&& let Some(format) = determine_format_from_path(path.as_str())
{
return Ok(format);
}
Ok(ImageFormat::detect(&loaded.data).ok_or("unknown image format")?)
}
}
/// Derive the image format from the file extension of a path.
fn determine_format_from_path(path: &str) -> Option<ImageFormat> {
let ext = std::path::Path::new(path)
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_lowercase();
match ext.as_str() {
// Raster formats
"png" => Some(ExchangeFormat::Png.into()),
"jpg" | "jpeg" => Some(ExchangeFormat::Jpg.into()),
"gif" => Some(ExchangeFormat::Gif.into()),
"webp" => Some(ExchangeFormat::Webp.into()),
// Vector formats
"svg" | "svgz" => Some(VectorFormat::Svg.into()),
"pdf" => Some(VectorFormat::Pdf.into()),
_ => None,
}
}
impl LocalName for Packed<ImageElem> {
const KEY: &'static str = "figure";
}

View File

@ -2,13 +2,19 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use comemo::Tracked;
use ecow::{EcoString, eco_format};
use rustc_hash::FxHashMap;
use siphasher::sip128::{Hasher128, SipHasher13};
use typst_syntax::FileId;
use crate::World;
use crate::diag::{LoadError, LoadResult, ReportPos, format_xml_like_error};
use crate::diag::{FileError, LoadError, LoadResult, ReportPos, format_xml_like_error};
use crate::foundations::Bytes;
use crate::layout::Axes;
use crate::visualize::VectorFormat;
use crate::visualize::image::raster::{ExchangeFormat, RasterFormat};
use crate::visualize::image::{ImageFormat, determine_format_from_path};
use crate::text::{
Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
};
@ -35,32 +41,47 @@ impl SvgImage {
Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash: 0, tree })))
}
/// Decode an SVG image with access to fonts.
/// Decode an SVG image with access to fonts and linked images.
#[comemo::memoize]
#[typst_macros::time(name = "load svg")]
pub fn with_fonts(
pub fn with_fonts_images(
data: Bytes,
world: Tracked<dyn World + '_>,
families: &[&str],
svg_file: Option<FileId>,
) -> LoadResult<SvgImage> {
let book = world.book();
let resolver = Mutex::new(FontResolver::new(world, book, families));
let font_resolver = Mutex::new(FontResolver::new(world, book, families));
let image_resolver = Mutex::new(ImageResolver::new(world, svg_file));
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)
font_resolver.lock().unwrap().select_font(font, db)
}),
select_fallback: Box::new(|c, exclude_fonts, db| {
resolver.lock().unwrap().select_fallback(c, exclude_fonts, db)
font_resolver.lock().unwrap().select_fallback(
c,
exclude_fonts,
db,
)
}),
},
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
resolve_string: Box::new(|href, _opts| {
image_resolver.lock().unwrap().load(href)
}),
},
..base_options()
},
)
.map_err(format_usvg_error)?;
let font_hash = resolver.into_inner().unwrap().finish();
if let Some(err) = image_resolver.into_inner().unwrap().error {
return Err(err);
}
let font_hash = font_resolver.into_inner().unwrap().finish();
Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash, tree })))
}
@ -287,3 +308,120 @@ impl FontResolver<'_> {
Some(id)
}
}
/// Resolves linked images in an SVG.
/// (Linked SVG images from an SVG are not supported yet.)
struct ImageResolver<'a> {
/// The world used to load linked images.
world: Tracked<'a, dyn World + 'a>,
/// Parent folder of the SVG file, used to resolve hrefs to linked images, if any.
svg_file: Option<FileId>,
/// The first error that occurred when loading a linked image, if any.
error: Option<LoadError>,
}
impl<'a> ImageResolver<'a> {
fn new(world: Tracked<'a, dyn World + 'a>, svg_file: Option<FileId>) -> Self {
Self { world, svg_file, error: None }
}
/// Load a linked image or return None if a previous image caused an error,
/// or if the linked image failed to load.
/// Only the first error message is retained.
fn load(&mut self, href: &str) -> Option<usvg::ImageKind> {
if self.error.is_some() {
return None;
}
match self.load_or_error(href) {
Ok(image) => Some(image),
Err(err) => {
self.error = Some(LoadError::new(
ReportPos::None,
eco_format!("failed to load linked image {} in SVG", href),
err,
));
None
}
}
}
/// Load a linked image or return an error message string.
fn load_or_error(&mut self, href: &str) -> Result<usvg::ImageKind, EcoString> {
// If the href starts with "file://", strip this prefix to construct an ordinary path.
let href = href.strip_prefix("file://").unwrap_or(href);
// Do not accept absolute hrefs. They would be parsed in typst in a way
// that is not compatible with their interpretation in the SVG standard.
if href.starts_with("/") {
return Err("absolute paths are not allowed".into());
}
// Exit early if the href is an URL.
if let Some(pos) = href.find("://") {
let scheme = &href[..pos];
if scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
{
return Err("URLs are not allowed".into());
}
}
// Resolve the path to the linked image.
if self.svg_file.is_none() {
return Err("cannot access file system from here".into());
}
// Replace the file name in svg_file by href.
let href_file = self.svg_file.unwrap().join(href);
// Load image if file can be accessed.
match self.world.file(href_file) {
Ok(bytes) => {
let arc_data = Arc::new(bytes.to_vec());
let format = match determine_format_from_path(href) {
Some(format) => Some(format),
None => ImageFormat::detect(&arc_data),
};
match format {
Some(ImageFormat::Vector(vector_format)) => match vector_format {
VectorFormat::Svg => {
Err("SVG images are not supported yet".into())
}
VectorFormat::Pdf => {
Err("PDF documents are not supported".into())
}
},
Some(ImageFormat::Raster(raster_format)) => match raster_format {
RasterFormat::Exchange(exchange_format) => {
match exchange_format {
ExchangeFormat::Gif => Ok(usvg::ImageKind::GIF(arc_data)),
ExchangeFormat::Jpg => {
Ok(usvg::ImageKind::JPEG(arc_data))
}
ExchangeFormat::Png => Ok(usvg::ImageKind::PNG(arc_data)),
ExchangeFormat::Webp => {
Ok(usvg::ImageKind::WEBP(arc_data))
}
}
}
RasterFormat::Pixel(_) => {
Err("pixel formats are not supported".into())
}
},
None => Err("unknown image format".into()),
}
}
// TODO: Somehow unify this with `impl Display for FileError`.
Err(err) => Err(match err {
FileError::NotFound(path) => {
eco_format!("file not found, searched at {}", path.display())
}
FileError::AccessDenied => "access denied".into(),
FileError::IsDirectory => "is a directory".into(),
FileError::Other(Some(msg)) => msg,
FileError::Other(None) => "unspecified error".into(),
_ => eco_format!("unexpected error: {}", err),
}),
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -79,6 +79,93 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
```.text
))
--- image-svg-linked-jpg1 ---
#set page(fill: gray)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg" height="80" width="48">
<image href="../../../assets/images/f2t.jpg" />
<circle r="32" cx="24" cy="40" fill="none" stroke="red" />
</svg>
```.text
))
--- image-svg-linked-jpg2 ---
#set page(fill: gray)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg" height="80" width="48">
<image href="file://../../../assets/images/f2t.jpg" />
<circle r="32" cx="24" cy="40" fill="none" stroke="blue" />
</svg>
```.text
))
--- image-svg-linked-many-formats ---
#set page(width: auto, height: auto, margin: 1pt)
#set text(1pt)
#image("../../../assets/images/linked.svg", width: 39pt)
--- image-svg-linked-file-not-found ---
// Error: 8-7:2 failed to load linked image do-not-add-image-with-this-name.png in SVG (file not found, searched at tests/suite/visualize/do-not-add-image-with-this-name.png)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="do-not-add-image-with-this-name.png" />
</svg>
```.text
))
--- image-svg-linked-url ---
// Error: 8-7:2 failed to load linked image https://somedomain.com/image.png in SVG (URLs are not allowed)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="https://somedomain.com/image.png" />
</svg>
```.text
))
--- image-svg-linked-pdf ---
// Error: 8-7:2 failed to load linked image ../../../assets/images/diagrams.pdf in SVG (PDF documents are not supported)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="../../../assets/images/diagrams.pdf" />
</svg>
```.text
))
--- image-svg-linked-csv ---
// Error: 8-7:2 failed to load linked image ../../../assets/data/bad.csv in SVG (unknown image format)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="../../../assets/data/bad.csv" />
</svg>
```.text
))
--- image-svg-linked-absolute1 ---
// Error: 8-7:2 failed to load linked image /home/user/foo.svg in SVG (absolute paths are not allowed)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="/home/user/foo.svg" />
</svg>
```.text
))
--- image-svg-linked-absolute2 ---
// Error: 8-7:2 failed to load linked image file:///home/user/foo.svg in SVG (absolute paths are not allowed)
#image(bytes(
```
<svg xmlns="http://www.w3.org/2000/svg">
<image href="file:///home/user/foo.svg" />
</svg>
```.text
))
--- image-pixmap-rgb8 ---
#image(
bytes((