diff options
| author | frozolotl <44589151+frozolotl@users.noreply.github.com> | 2025-01-31 10:56:25 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-01-31 09:56:25 +0000 |
| commit | 3eb6e87af1d8870a38cc5914e345d07373e1e8c1 (patch) | |
| tree | 800de2bc1ed9a5c7f8efc21e2741c0cd5c4728f6 /crates/typst-library/src/visualize | |
| parent | be1fa91a00a9bff6c5eb9744266f252b8cc23fe4 (diff) | |
Include images from raw pixmaps and more (#5632)
Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com>
Co-authored-by: Laurenz <laurmaedje@gmail.com>
Diffstat (limited to 'crates/typst-library/src/visualize')
| -rw-r--r-- | crates/typst-library/src/visualize/image/mod.rs | 216 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/image/raster.rs | 267 | ||||
| -rw-r--r-- | crates/typst-library/src/visualize/image/svg.rs | 2 |
3 files changed, 365 insertions, 120 deletions
diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 77f8426e..0e5c9e32 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -3,13 +3,14 @@ mod raster; mod svg; -pub use self::raster::{RasterFormat, RasterImage}; +pub use self::raster::{ + ExchangeFormat, PixelEncoding, PixelFormat, RasterFormat, RasterImage, +}; pub use self::svg::SvgImage; use std::fmt::{self, Debug, Formatter}; use std::sync::Arc; -use comemo::Tracked; use ecow::EcoString; use typst_syntax::{Span, Spanned}; use typst_utils::LazyHash; @@ -24,7 +25,6 @@ use crate::layout::{BlockElem, Length, Rel, Sizing}; use crate::loading::{DataSource, Load, Readable}; use crate::model::Figurable; use crate::text::LocalName; -use crate::World; /// A raster or vector graphic. /// @@ -46,7 +46,8 @@ use crate::World; /// ``` #[elem(scope, Show, LocalName, Figurable)] pub struct ImageElem { - /// A path to an image file or raw bytes making up an encoded image. + /// A path to an image file or raw bytes making up an image in one of the + /// supported [formats]($image.format). /// /// For more details about paths, see the [Paths section]($syntax/#paths). #[required] @@ -57,10 +58,50 @@ pub struct ImageElem { )] pub source: Derived<DataSource, Bytes>, - /// The image's format. Detected automatically by default. + /// The image's format. + /// + /// By default, the format is detected automatically. Typically, you thus + /// only need to specify this when providing raw bytes as the + /// [`source`]($image.source) (even then, Typst will try to figure out the + /// format automatically, but that's not always possible). + /// + /// Supported formats are `{"png"}`, `{"jpg"}`, `{"gif"}`, `{"svg"}` as well + /// as raw pixel data. Embedding PDFs as images is + /// [not currently supported](https://github.com/typst/typst/issues/145). + /// + /// When providing raw pixel data as the `source`, you must specify a + /// dictionary with the following keys as the `format`: + /// - `encoding` ([str]): The encoding of the pixel data. One of: + /// - `{"rgb8"}` (three 8-bit channels: red, green, blue) + /// - `{"rgba8"}` (four 8-bit channels: red, green, blue, alpha) + /// - `{"luma8"}` (one 8-bit channel) + /// - `{"lumaa8"}` (two 8-bit channels: luma and alpha) + /// - `width` ([int]): The pixel width of the image. + /// - `height` ([int]): The pixel height of the image. + /// + /// The pixel width multiplied by the height multiplied by the channel count + /// for the specified encoding must then match the `source` data. + /// + /// ```example + /// #image( + /// read( + /// "tetrahedron.svg", + /// encoding: none, + /// ), + /// format: "svg", + /// width: 2cm, + /// ) /// - /// Supported formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image - /// is [not currently supported](https://github.com/typst/typst/issues/145). + /// #image( + /// bytes(range(16).map(x => x * 16)), + /// format: ( + /// encoding: "luma8", + /// width: 4, + /// height: 4, + /// ), + /// width: 2cm, + /// ) + /// ``` pub format: Smart<ImageFormat>, /// The width of the image. @@ -86,6 +127,30 @@ pub struct ImageElem { #[default(ImageFit::Cover)] pub fit: ImageFit, + /// A hint to viewers how they should scale the image. + /// + /// When set to `{auto}`, the default is left up to the viewer. For PNG + /// export, Typst will default to smooth scaling, like most PDF and SVG + /// viewers. + /// + /// _Note:_ The exact look may differ across PDF viewers. + pub scaling: Smart<ImageScaling>, + + /// An ICC profile for the image. + /// + /// ICC profiles define how to interpret the colors in an image. When set + /// to `{auto}`, Typst will try to extract an ICC profile from the image. + #[parse(match args.named::<Spanned<Smart<DataSource>>>("icc")? { + Some(Spanned { v: Smart::Custom(source), span }) => Some(Smart::Custom({ + let data = Spanned::new(&source, span).load(engine.world)?; + Derived::new(source, data) + })), + Some(Spanned { v: Smart::Auto, .. }) => Some(Smart::Auto), + None => None, + })] + #[borrowed] + pub icc: Smart<Derived<DataSource, Bytes>>, + /// Whether text in SVG images should be converted into curves before /// embedding. This will result in the text becoming unselectable in the /// output. @@ -94,6 +159,7 @@ pub struct ImageElem { } #[scope] +#[allow(clippy::too_many_arguments)] impl ImageElem { /// Decode a raster or vector graphic from bytes or a string. /// @@ -130,6 +196,13 @@ impl ImageElem { /// How the image should adjust itself to a given area. #[named] fit: Option<ImageFit>, + /// A hint to viewers how they should scale the image. + #[named] + scaling: Option<Smart<ImageScaling>>, + /// Whether text in SVG images should be converted into curves before + /// embedding. + #[named] + flatten_text: Option<bool>, ) -> StrResult<Content> { let bytes = data.into_bytes(); let source = Derived::new(DataSource::Bytes(bytes.clone()), bytes); @@ -149,6 +222,12 @@ impl ImageElem { if let Some(fit) = fit { elem.push_fit(fit); } + if let Some(scaling) = scaling { + elem.push_scaling(scaling); + } + if let Some(flatten_text) = flatten_text { + elem.push_flatten_text(flatten_text); + } Ok(elem.pack().spanned(span)) } } @@ -199,15 +278,8 @@ struct Repr { kind: ImageKind, /// A text describing the image. alt: Option<EcoString>, -} - -/// A kind of image. -#[derive(Hash)] -pub enum ImageKind { - /// A raster image. - Raster(RasterImage), - /// An SVG image. - Svg(SvgImage), + /// The scaling algorithm to use. + scaling: Smart<ImageScaling>, } impl Image { @@ -218,55 +290,29 @@ impl Image { /// Should always be the same as the default DPI used by usvg. pub const USVG_DEFAULT_DPI: f64 = 96.0; - /// Create an image from a buffer and a format. - #[comemo::memoize] - #[typst_macros::time(name = "load image")] + /// Create an image from a `RasterImage` or `SvgImage`. pub fn new( - data: Bytes, - format: ImageFormat, + kind: impl Into<ImageKind>, alt: Option<EcoString>, - ) -> StrResult<Image> { - let kind = match format { - ImageFormat::Raster(format) => { - ImageKind::Raster(RasterImage::new(data, format)?) - } - ImageFormat::Vector(VectorFormat::Svg) => { - ImageKind::Svg(SvgImage::new(data)?) - } - }; - - Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt })))) + scaling: Smart<ImageScaling>, + ) -> Self { + Self::new_impl(kind.into(), alt, scaling) } - /// Create a possibly font-dependent image from a buffer and a format. - #[comemo::memoize] - #[typst_macros::time(name = "load image")] - pub fn with_fonts( - data: Bytes, - format: ImageFormat, - alt: Option<EcoString>, - world: Tracked<dyn World + '_>, - families: &[&str], - flatten_text: bool, - ) -> StrResult<Image> { - let kind = match format { - ImageFormat::Raster(format) => { - ImageKind::Raster(RasterImage::new(data, format)?) - } - ImageFormat::Vector(VectorFormat::Svg) => { - ImageKind::Svg(SvgImage::with_fonts(data, world, flatten_text, families)?) - } - }; - - Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt })))) + /// Create an image with optional properties set to the default. + pub fn plain(kind: impl Into<ImageKind>) -> Self { + Self::new(kind, None, Smart::Auto) } - /// The raw image data. - pub fn data(&self) -> &Bytes { - match &self.0.kind { - ImageKind::Raster(raster) => raster.data(), - ImageKind::Svg(svg) => svg.data(), - } + /// The internal, non-generic implementation. This is memoized to reuse + /// the `Arc` and `LazyHash`. + #[comemo::memoize] + fn new_impl( + kind: ImageKind, + alt: Option<EcoString>, + scaling: Smart<ImageScaling>, + ) -> Image { + Self(Arc::new(LazyHash::new(Repr { kind, alt, scaling }))) } /// The format of the image. @@ -306,6 +352,11 @@ impl Image { self.0.alt.as_deref() } + /// The image scaling algorithm to use for this image. + pub fn scaling(&self) -> Smart<ImageScaling> { + self.0.scaling + } + /// The decoded image. pub fn kind(&self) -> &ImageKind { &self.0.kind @@ -319,10 +370,32 @@ impl Debug for Image { .field("width", &self.width()) .field("height", &self.height()) .field("alt", &self.alt()) + .field("scaling", &self.scaling()) .finish() } } +/// A kind of image. +#[derive(Clone, Hash)] +pub enum ImageKind { + /// A raster image. + Raster(RasterImage), + /// An SVG image. + Svg(SvgImage), +} + +impl From<RasterImage> for ImageKind { + fn from(image: RasterImage) -> Self { + Self::Raster(image) + } +} + +impl From<SvgImage> for ImageKind { + fn from(image: SvgImage) -> Self { + Self::Svg(image) + } +} + /// A raster or vector image format. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum ImageFormat { @@ -335,8 +408,8 @@ pub enum ImageFormat { impl ImageFormat { /// Try to detect the format of an image from data. pub fn detect(data: &[u8]) -> Option<Self> { - if let Some(format) = RasterFormat::detect(data) { - return Some(Self::Raster(format)); + if let Some(format) = ExchangeFormat::detect(data) { + return Some(Self::Raster(RasterFormat::Exchange(format))); } // SVG or compressed SVG. @@ -355,9 +428,12 @@ pub enum VectorFormat { Svg, } -impl From<RasterFormat> for ImageFormat { - fn from(format: RasterFormat) -> Self { - Self::Raster(format) +impl<R> From<R> for ImageFormat +where + R: Into<RasterFormat>, +{ + fn from(format: R) -> Self { + Self::Raster(format.into()) } } @@ -371,8 +447,18 @@ cast! { ImageFormat, self => match self { Self::Raster(v) => v.into_value(), - Self::Vector(v) => v.into_value() + Self::Vector(v) => v.into_value(), }, v: RasterFormat => Self::Raster(v), v: VectorFormat => Self::Vector(v), } + +/// The image scaling algorithm a viewer should use. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ImageScaling { + /// Scale with a smoothing algorithm such as bilinear interpolation. + Smooth, + /// Scale with nearest neighbor or a similar algorithm to preserve the + /// pixelated look of the image. + Pixelated, +} diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 098843a2..d43b1548 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -7,10 +7,12 @@ use ecow::{eco_format, EcoString}; use image::codecs::gif::GifDecoder; use image::codecs::jpeg::JpegDecoder; use image::codecs::png::PngDecoder; -use image::{guess_format, DynamicImage, ImageDecoder, ImageResult, Limits}; +use image::{ + guess_format, DynamicImage, ImageBuffer, ImageDecoder, ImageResult, Limits, Pixel, +}; use crate::diag::{bail, StrResult}; -use crate::foundations::{Bytes, Cast}; +use crate::foundations::{cast, dict, Bytes, Cast, Dict, Smart, Value}; /// A decoded raster image. #[derive(Clone, Hash)] @@ -21,43 +23,118 @@ struct Repr { data: Bytes, format: RasterFormat, dynamic: image::DynamicImage, - icc: Option<Vec<u8>>, + icc: Option<Bytes>, dpi: Option<f64>, } impl RasterImage { /// Decode a raster image. - #[comemo::memoize] - pub fn new(data: Bytes, format: RasterFormat) -> StrResult<RasterImage> { - fn decode_with<T: ImageDecoder>( - decoder: ImageResult<T>, - ) -> ImageResult<(image::DynamicImage, Option<Vec<u8>>)> { - let mut decoder = decoder?; - let icc = decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty()); - decoder.set_limits(Limits::default())?; - let dynamic = image::DynamicImage::from_decoder(decoder)?; - Ok((dynamic, icc)) - } - - let cursor = io::Cursor::new(&data); - let (mut dynamic, icc) = match format { - RasterFormat::Jpg => decode_with(JpegDecoder::new(cursor)), - RasterFormat::Png => decode_with(PngDecoder::new(cursor)), - RasterFormat::Gif => decode_with(GifDecoder::new(cursor)), - } - .map_err(format_image_error)?; - - let exif = exif::Reader::new() - .read_from_container(&mut std::io::Cursor::new(&data)) - .ok(); + pub fn new( + data: Bytes, + format: impl Into<RasterFormat>, + icc: Smart<Bytes>, + ) -> StrResult<Self> { + Self::new_impl(data, format.into(), icc) + } - // Apply rotation from EXIF metadata. - if let Some(rotation) = exif.as_ref().and_then(exif_rotation) { - apply_rotation(&mut dynamic, rotation); - } + /// Create a raster image with optional properties set to the default. + pub fn plain(data: Bytes, format: impl Into<RasterFormat>) -> StrResult<Self> { + Self::new(data, format, Smart::Auto) + } - // Extract pixel density. - let dpi = determine_dpi(&data, exif.as_ref()); + /// The internal, non-generic implementation. + #[comemo::memoize] + #[typst_macros::time(name = "load raster image")] + fn new_impl( + data: Bytes, + format: RasterFormat, + icc: Smart<Bytes>, + ) -> StrResult<RasterImage> { + let (dynamic, icc, dpi) = match format { + RasterFormat::Exchange(format) => { + fn decode<T: ImageDecoder>( + decoder: ImageResult<T>, + icc: Smart<Bytes>, + ) -> ImageResult<(image::DynamicImage, Option<Bytes>)> { + let mut decoder = decoder?; + let icc = icc.custom().or_else(|| { + decoder + .icc_profile() + .ok() + .flatten() + .filter(|icc| !icc.is_empty()) + .map(Bytes::new) + }); + decoder.set_limits(Limits::default())?; + let dynamic = image::DynamicImage::from_decoder(decoder)?; + Ok((dynamic, icc)) + } + + let cursor = io::Cursor::new(&data); + let (mut dynamic, icc) = match format { + ExchangeFormat::Jpg => decode(JpegDecoder::new(cursor), icc), + ExchangeFormat::Png => decode(PngDecoder::new(cursor), icc), + ExchangeFormat::Gif => decode(GifDecoder::new(cursor), icc), + } + .map_err(format_image_error)?; + + let exif = exif::Reader::new() + .read_from_container(&mut std::io::Cursor::new(&data)) + .ok(); + + // Apply rotation from EXIF metadata. + if let Some(rotation) = exif.as_ref().and_then(exif_rotation) { + apply_rotation(&mut dynamic, rotation); + } + + // Extract pixel density. + let dpi = determine_dpi(&data, exif.as_ref()); + + (dynamic, icc, dpi) + } + + RasterFormat::Pixel(format) => { + if format.width == 0 || format.height == 0 { + bail!("zero-sized images are not allowed"); + } + + let channels = match format.encoding { + PixelEncoding::Rgb8 => 3, + PixelEncoding::Rgba8 => 4, + PixelEncoding::Luma8 => 1, + PixelEncoding::Lumaa8 => 2, + }; + + let Some(expected_size) = format + .width + .checked_mul(format.height) + .and_then(|size| size.checked_mul(channels)) + else { + bail!("pixel dimensions are too large"); + }; + + if expected_size as usize != data.len() { + bail!("pixel dimensions and pixel data do not match"); + } + + fn to<P: Pixel<Subpixel = u8>>( + data: &Bytes, + format: PixelFormat, + ) -> ImageBuffer<P, Vec<u8>> { + ImageBuffer::from_raw(format.width, format.height, data.to_vec()) + .unwrap() + } + + let dynamic = match format.encoding { + PixelEncoding::Rgb8 => to::<image::Rgb<u8>>(&data, format).into(), + PixelEncoding::Rgba8 => to::<image::Rgba<u8>>(&data, format).into(), + PixelEncoding::Luma8 => to::<image::Luma<u8>>(&data, format).into(), + PixelEncoding::Lumaa8 => to::<image::LumaA<u8>>(&data, format).into(), + }; + + (dynamic, icc.custom(), None) + } + }; Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) } @@ -93,60 +170,141 @@ impl RasterImage { } /// Access the ICC profile, if any. - pub fn icc(&self) -> Option<&[u8]> { - self.0.icc.as_deref() + pub fn icc(&self) -> Option<&Bytes> { + self.0.icc.as_ref() } } impl Hash for Repr { fn hash<H: Hasher>(&self, state: &mut H) { - // The image is fully defined by data and format. + // The image is fully defined by data, format, and ICC profile. self.data.hash(state); self.format.hash(state); + self.icc.hash(state); } } /// A raster graphics format. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum RasterFormat { + /// A format typically used in image exchange. + Exchange(ExchangeFormat), + /// A format of raw pixel data. + Pixel(PixelFormat), +} + +impl From<ExchangeFormat> for RasterFormat { + fn from(format: ExchangeFormat) -> Self { + Self::Exchange(format) + } +} + +impl From<PixelFormat> for RasterFormat { + fn from(format: PixelFormat) -> Self { + Self::Pixel(format) + } +} + +cast! { + RasterFormat, + self => match self { + Self::Exchange(v) => v.into_value(), + Self::Pixel(v) => v.into_value(), + }, + v: ExchangeFormat => Self::Exchange(v), + v: PixelFormat => Self::Pixel(v), +} + +/// A raster format typically used in image exchange, with efficient encoding. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ExchangeFormat { /// Raster format for illustrations and transparent graphics. Png, /// Lossy raster format suitable for photos. Jpg, - /// Raster format that is typically used for short animated clips. + /// Raster format that is typically used for short animated clips. Typst can + /// load GIFs, but they will become static. Gif, } -impl RasterFormat { +impl ExchangeFormat { /// Try to detect the format of data in a buffer. pub fn detect(data: &[u8]) -> Option<Self> { guess_format(data).ok().and_then(|format| format.try_into().ok()) } } -impl From<RasterFormat> for image::ImageFormat { - fn from(format: RasterFormat) -> Self { +impl From<ExchangeFormat> for image::ImageFormat { + fn from(format: ExchangeFormat) -> Self { match format { - RasterFormat::Png => image::ImageFormat::Png, - RasterFormat::Jpg => image::ImageFormat::Jpeg, - RasterFormat::Gif => image::ImageFormat::Gif, + ExchangeFormat::Png => image::ImageFormat::Png, + ExchangeFormat::Jpg => image::ImageFormat::Jpeg, + ExchangeFormat::Gif => image::ImageFormat::Gif, } } } -impl TryFrom<image::ImageFormat> for RasterFormat { +impl TryFrom<image::ImageFormat> for ExchangeFormat { type Error = EcoString; fn try_from(format: image::ImageFormat) -> StrResult<Self> { Ok(match format { - image::ImageFormat::Png => RasterFormat::Png, - image::ImageFormat::Jpeg => RasterFormat::Jpg, - image::ImageFormat::Gif => RasterFormat::Gif, - _ => bail!("Format not yet supported."), + image::ImageFormat::Png => ExchangeFormat::Png, + image::ImageFormat::Jpeg => ExchangeFormat::Jpg, + image::ImageFormat::Gif => ExchangeFormat::Gif, + _ => bail!("format not yet supported"), }) } } +/// Information that is needed to understand a pixmap buffer. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct PixelFormat { + /// The channel encoding. + encoding: PixelEncoding, + /// The pixel width. + width: u32, + /// The pixel height. + height: u32, +} + +/// Determines the channel encoding of raw pixel data. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum PixelEncoding { + /// Three 8-bit channels: Red, green, blue. + Rgb8, + /// Four 8-bit channels: Red, green, blue, alpha. + Rgba8, + /// One 8-bit channel. + Luma8, + /// Two 8-bit channels: Luma and alpha. + Lumaa8, +} + +cast! { + PixelFormat, + self => Value::Dict(self.into()), + mut dict: Dict => { + let format = Self { + encoding: dict.take("encoding")?.cast()?, + width: dict.take("width")?.cast()?, + height: dict.take("height")?.cast()?, + }; + dict.finish(&["encoding", "width", "height"])?; + format + } +} + +impl From<PixelFormat> for Dict { + fn from(format: PixelFormat) -> Self { + dict! { + "encoding" => format.encoding, + "width" => format.width, + "height" => format.height, + } + } +} + /// Try to get the rotation from the EXIF metadata. fn exif_rotation(exif: &exif::Exif) -> Option<u32> { exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)? @@ -266,21 +424,20 @@ fn format_image_error(error: image::ImageError) -> EcoString { #[cfg(test)] mod tests { - use super::{RasterFormat, RasterImage}; - use crate::foundations::Bytes; + use super::*; #[test] fn test_image_dpi() { #[track_caller] - fn test(path: &str, format: RasterFormat, dpi: f64) { + fn test(path: &str, format: ExchangeFormat, dpi: f64) { let data = typst_dev_assets::get(path).unwrap(); let bytes = Bytes::new(data); - let image = RasterImage::new(bytes, format).unwrap(); + let image = RasterImage::plain(bytes, format).unwrap(); assert_eq!(image.dpi().map(f64::round), Some(dpi)); } - test("images/f2t.jpg", RasterFormat::Jpg, 220.0); - test("images/tiger.jpg", RasterFormat::Jpg, 72.0); - test("images/graph.png", RasterFormat::Png, 144.0); + test("images/f2t.jpg", ExchangeFormat::Jpg, 220.0); + test("images/tiger.jpg", ExchangeFormat::Jpg, 72.0); + test("images/graph.png", ExchangeFormat::Png, 144.0); } } diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs index 089f0543..dcc55077 100644 --- a/crates/typst-library/src/visualize/image/svg.rs +++ b/crates/typst-library/src/visualize/image/svg.rs @@ -30,6 +30,7 @@ struct Repr { impl SvgImage { /// Decode an SVG image without fonts. #[comemo::memoize] + #[typst_macros::time(name = "load svg")] pub fn new(data: Bytes) -> StrResult<SvgImage> { let tree = usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?; @@ -44,6 +45,7 @@ impl SvgImage { /// Decode an SVG image with access to fonts. #[comemo::memoize] + #[typst_macros::time(name = "load svg")] pub fn with_fonts( data: Bytes, world: Tracked<dyn World + '_>, |
