Load linked bitmap images in SVG images (#6794)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
parent
a880ac8bbf
commit
560e49b67c
|
|
@ -2975,7 +2975,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typst-dev-assets"
|
name = "typst-dev-assets"
|
||||||
version = "0.13.1"
|
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]]
|
[[package]]
|
||||||
name = "typst-docs"
|
name = "typst-docs"
|
||||||
|
|
|
||||||
|
|
@ -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-timing = { path = "crates/typst-timing", version = "0.13.1" }
|
||||||
typst-utils = { path = "crates/typst-utils", 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-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"
|
arrayvec = "0.7.4"
|
||||||
az = "1.2"
|
az = "1.2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ pub type LoadResult<T> = Result<T, LoadError>;
|
||||||
/// [`FileId`]: typst_syntax::FileId
|
/// [`FileId`]: typst_syntax::FileId
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct LoadError {
|
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,
|
pos: ReportPos,
|
||||||
/// Must contain a message formatted like this: `"failed to do thing (cause)"`.
|
/// Must contain a message formatted like this: `"failed to do thing (cause)"`.
|
||||||
message: EcoString,
|
message: EcoString,
|
||||||
|
|
|
||||||
|
|
@ -265,14 +265,22 @@ impl Packed<ImageElem> {
|
||||||
)
|
)
|
||||||
.at(span)?,
|
.at(span)?,
|
||||||
),
|
),
|
||||||
ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg(
|
ImageFormat::Vector(VectorFormat::Svg) => {
|
||||||
SvgImage::with_fonts(
|
// Identify the SVG file in case contained hrefs need to be resolved.
|
||||||
loaded.data.clone(),
|
let svg_file = match self.source.source {
|
||||||
engine.world,
|
DataSource::Path(ref path) => span.resolve_path(path).ok(),
|
||||||
&families(styles).map(|f| f.as_str()).collect::<Vec<_>>(),
|
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) => {
|
ImageFormat::Vector(VectorFormat::Pdf) => {
|
||||||
let document = match PdfDocument::new(loaded.data.clone()) {
|
let document = match PdfDocument::new(loaded.data.clone()) {
|
||||||
Ok(doc) => doc,
|
Ok(doc) => doc,
|
||||||
|
|
@ -325,28 +333,37 @@ impl Packed<ImageElem> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let Derived { source, derived: loaded } = &self.source;
|
let Derived { source, derived: loaded } = &self.source;
|
||||||
if let DataSource::Path(path) = source {
|
if let DataSource::Path(path) = source
|
||||||
let ext = std::path::Path::new(path.as_str())
|
&& let Some(format) = determine_format_from_path(path.as_str())
|
||||||
.extension()
|
{
|
||||||
.and_then(OsStr::to_str)
|
return Ok(format);
|
||||||
.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()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ImageFormat::detect(&loaded.data).ok_or("unknown image 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> {
|
impl LocalName for Packed<ImageElem> {
|
||||||
const KEY: &'static str = "figure";
|
const KEY: &'static str = "figure";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@ use std::hash::{Hash, Hasher};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use comemo::Tracked;
|
use comemo::Tracked;
|
||||||
|
use ecow::{EcoString, eco_format};
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use siphasher::sip128::{Hasher128, SipHasher13};
|
use siphasher::sip128::{Hasher128, SipHasher13};
|
||||||
|
use typst_syntax::FileId;
|
||||||
|
|
||||||
use crate::World;
|
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::foundations::Bytes;
|
||||||
use crate::layout::Axes;
|
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::{
|
use crate::text::{
|
||||||
Font, FontBook, FontFlags, FontStretch, FontStyle, FontVariant, FontWeight,
|
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 })))
|
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]
|
#[comemo::memoize]
|
||||||
#[typst_macros::time(name = "load svg")]
|
#[typst_macros::time(name = "load svg")]
|
||||||
pub fn with_fonts(
|
pub fn with_fonts_images(
|
||||||
data: Bytes,
|
data: Bytes,
|
||||||
world: Tracked<dyn World + '_>,
|
world: Tracked<dyn World + '_>,
|
||||||
families: &[&str],
|
families: &[&str],
|
||||||
|
svg_file: Option<FileId>,
|
||||||
) -> LoadResult<SvgImage> {
|
) -> LoadResult<SvgImage> {
|
||||||
let book = world.book();
|
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(
|
let tree = usvg::Tree::from_data(
|
||||||
&data,
|
&data,
|
||||||
&usvg::Options {
|
&usvg::Options {
|
||||||
font_resolver: usvg::FontResolver {
|
font_resolver: usvg::FontResolver {
|
||||||
select_font: Box::new(|font, db| {
|
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| {
|
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()
|
..base_options()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(format_usvg_error)?;
|
.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 })))
|
Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash, tree })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,3 +308,120 @@ impl FontResolver<'_> {
|
||||||
Some(id)
|
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 |
|
|
@ -79,6 +79,93 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
|
||||||
```.text
|
```.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-pixmap-rgb8 ---
|
||||||
#image(
|
#image(
|
||||||
bytes((
|
bytes((
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue