diff --git a/Cargo.lock b/Cargo.lock index 0930c4f5..99253a7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1ab48f54..0214e0bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs index 018f1616..e401c11e 100644 --- a/crates/typst-library/src/diag.rs +++ b/crates/typst-library/src/diag.rs @@ -593,7 +593,7 @@ pub type LoadResult = Result; /// [`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, diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index ceaca1bb..7f4c4192 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -265,14 +265,22 @@ impl Packed { ) .at(span)?, ), - ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( - SvgImage::with_fonts( - loaded.data.clone(), - engine.world, - &families(styles).map(|f| f.as_str()).collect::>(), + 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::>(), + 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 { }; 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 { + 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 { const KEY: &'static str = "figure"; } diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs index dab6eaa0..56e25ad4 100644 --- a/crates/typst-library/src/visualize/image/svg.rs +++ b/crates/typst-library/src/visualize/image/svg.rs @@ -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, families: &[&str], + svg_file: Option, ) -> LoadResult { 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, + /// The first error that occurred when loading a linked image, if any. + error: Option, +} + +impl<'a> ImageResolver<'a> { + fn new(world: Tracked<'a, dyn World + 'a>, svg_file: Option) -> 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 { + 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 { + // 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), + }), + } + } +} diff --git a/tests/ref/image-svg-linked-jpg1.png b/tests/ref/image-svg-linked-jpg1.png new file mode 100644 index 00000000..c21846d6 Binary files /dev/null and b/tests/ref/image-svg-linked-jpg1.png differ diff --git a/tests/ref/image-svg-linked-jpg2.png b/tests/ref/image-svg-linked-jpg2.png new file mode 100644 index 00000000..05748d82 Binary files /dev/null and b/tests/ref/image-svg-linked-jpg2.png differ diff --git a/tests/ref/image-svg-linked-many-formats.png b/tests/ref/image-svg-linked-many-formats.png new file mode 100644 index 00000000..86bcdc51 Binary files /dev/null and b/tests/ref/image-svg-linked-many-formats.png differ diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 97736622..e2b7fbd1 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -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( + ``` + + + + + ```.text +)) + +--- image-svg-linked-jpg2 --- +#set page(fill: gray) +#image(bytes( + ``` + + + + + ```.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( + ``` + + + + ```.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( + ``` + + + + ```.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( + ``` + + + + ```.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( + ``` + + + + ```.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( + ``` + + + + ```.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( + ``` + + + + ```.text +)) + --- image-pixmap-rgb8 --- #image( bytes((