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]]
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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((
|
||||
|
|
|
|||
Loading…
Reference in New Issue