diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index f1256f369..83c282133 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; -use typst_library::diag::{bail, warning, At, SourceResult, StrResult}; +use typst_library::diag::{warning, At, SourceResult, StrResult}; use typst_library::engine::Engine; use typst_library::foundations::{Bytes, Derived, Packed, Smart, StyleChain}; use typst_library::introspection::Locator; @@ -10,8 +10,8 @@ use typst_library::layout::{ use typst_library::loading::DataSource; use typst_library::text::families; use typst_library::visualize::{ - Curve, Image, ImageElem, ImageFit, ImageFormat, ImageKind, ImageSource, PixmapImage, - RasterFormat, RasterImage, SvgImage, VectorFormat, + Curve, ExchangeFormat, Image, ImageElem, ImageFit, ImageFormat, ImageKind, + RasterImage, SvgImage, VectorFormat, }; /// Layout the image. @@ -50,14 +50,11 @@ pub fn layout_image( } // Construct the image itself. - let kind = match (format, source) { - (ImageFormat::Pixmap(format), ImageSource::Pixmap(source)) => { - ImageKind::Pixmap(PixmapImage::new(source.clone(), format).at(span)?) - } - (ImageFormat::Raster(format), ImageSource::Data(_)) => { + let kind = match format { + ImageFormat::Raster(format) => { ImageKind::Raster(RasterImage::new(data.clone(), format).at(span)?) } - (ImageFormat::Vector(VectorFormat::Svg), ImageSource::Data(_)) => ImageKind::Svg( + ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( SvgImage::with_fonts( data.clone(), engine.world, @@ -66,10 +63,6 @@ pub fn layout_image( ) .at(span)?, ), - (ImageFormat::Pixmap(_), _) => bail!(span, "source must be a pixmap"), - (ImageFormat::Raster(_) | ImageFormat::Vector(_), _) => { - bail!(span, "expected readable source for the given format (str or bytes)") - } }; let image = Image::new(kind, elem.alt(styles), elem.scaling(styles)); @@ -135,26 +128,20 @@ pub fn layout_image( } /// Try to determine the image format based on the data. -fn determine_format(source: &ImageSource, data: &Bytes) -> StrResult { - match source { - ImageSource::Data(DataSource::Path(path)) => { - let ext = std::path::Path::new(path.as_str()) - .extension() - .and_then(OsStr::to_str) - .unwrap_or_default() - .to_lowercase(); +fn determine_format(source: &DataSource, data: &Bytes) -> StrResult { + 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(ImageFormat::Raster(RasterFormat::Png)), - "jpg" | "jpeg" => return Ok(ImageFormat::Raster(RasterFormat::Jpg)), - "gif" => return Ok(ImageFormat::Raster(RasterFormat::Gif)), - "svg" | "svgz" => return Ok(ImageFormat::Vector(VectorFormat::Svg)), - _ => {} - } - } - ImageSource::Data(DataSource::Bytes(_)) => {} - ImageSource::Pixmap(_) => { - bail!("pixmaps require an explicit image format to be given") + 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()), + _ => {} } } diff --git a/crates/typst-library/src/text/font/color.rs b/crates/typst-library/src/text/font/color.rs index 93bce1dfd..3a904dc6f 100644 --- a/crates/typst-library/src/text/font/color.rs +++ b/crates/typst-library/src/text/font/color.rs @@ -11,7 +11,7 @@ use crate::foundations::{Bytes, Smart}; use crate::layout::{Abs, Frame, FrameItem, Point, Size}; use crate::text::{Font, Glyph}; use crate::visualize::{ - FixedStroke, Geometry, Image, RasterFormat, RasterImage, SvgImage, + ExchangeFormat, FixedStroke, Geometry, Image, RasterImage, SvgImage, }; /// Whether this glyph should be rendered via simple outlining instead of via @@ -106,7 +106,7 @@ fn draw_raster_glyph( ) -> Option<()> { let data = Bytes::new(raster_image.data.to_vec()); let image = - Image::new(RasterImage::new(data, RasterFormat::Png).ok()?, None, Smart::Auto); + Image::new(RasterImage::new(data, ExchangeFormat::Png).ok()?, None, Smart::Auto); // Apple Color emoji doesn't provide offset information (or at least // not in a way ttf-parser understands), so we artificially shift their diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 80dc1fa53..7ee96378d 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -1,17 +1,16 @@ //! Image handling. -mod pixmap; mod raster; mod svg; -pub use self::pixmap::{PixmapFormat, PixmapImage, PixmapSource}; -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; @@ -26,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. /// @@ -48,34 +46,54 @@ use crate::World; /// ``` #[elem(scope, Show, LocalName, Figurable)] pub struct ImageElem { - /// The source to load the image from. Either of: + /// A path to an image file or raw bytes making up an image in one of the + /// supported [formats]($image.format). /// - /// - A path to an image file. For more details about paths, see the [Paths - /// section]($syntax/#paths). - /// - Raw bytes making up an encoded image. - /// - A dictionary with the following keys: - /// - `data` ([bytes]): Raw pixel data in the specified [`format`]($image.format). - /// - `pixel-width` ([int]): The width in pixels. - /// - `pixel-height` ([int]): The height in pixels. - /// - `icc-profile` ([bytes], optional): An ICC profile for the image. - /// - /// The width multiplied by the height multiplied by the channel count for - /// the specified format must match the data length. + /// For more details about paths, see the [Paths section]($syntax/#paths). #[required] #[parse( - let source = args.expect::>("source")?; + let source = args.expect::>("source")?; let data = source.load(engine.world)?; Derived::new(source.v, data) )] - pub source: Derived, + pub source: Derived, - /// The image's format. Detected automatically by default. + /// The image's format. /// - /// Supported image formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image - /// is [not currently supported](https://github.com/typst/typst/issues/145). + /// By default, the format is detected automatically. Typically, you thus + /// only need to specify this when providing raw bytes as the `source` ( + /// even then, Typst will try to figure out the format automatically, but + /// that's not always possible). /// - /// Aside from these encoded image formats, Typst also lets you provide raw - /// image data as the source. In this case, providing a format is mandatory. + /// Supported formats include common exchange image formats (`{"png"}`, + /// `{"jpg"}`, `{"gif"}`, and `{"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`]($image.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: Brightness.) + /// - `{"lumaa8"}` (two 8-bit channels: Brightness 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( + /// bytes(range(16).map(x => x * 16)), + /// format: ( + /// encoding: "luma8", + /// width: 4, + /// height: 4, + /// ), + /// width: 1cm, + /// ) + /// ``` pub format: Smart, /// The width of the image. @@ -164,8 +182,7 @@ impl ImageElem { flatten_text: Option, ) -> StrResult { let bytes = data.into_bytes(); - let source = - Derived::new(ImageSource::Data(DataSource::Bytes(bytes.clone())), bytes); + let source = Derived::new(DataSource::Bytes(bytes.clone()), bytes); let mut elem = ImageElem::new(source); if let Some(format) = format { elem.push_format(format); @@ -278,7 +295,6 @@ impl Image { match &self.0.kind { ImageKind::Raster(raster) => raster.format().into(), ImageKind::Svg(_) => VectorFormat::Svg.into(), - ImageKind::Pixmap(pixmap) => pixmap.format().into(), } } @@ -287,7 +303,6 @@ impl Image { match &self.0.kind { ImageKind::Raster(raster) => raster.width() as f64, ImageKind::Svg(svg) => svg.width(), - ImageKind::Pixmap(pixmap) => pixmap.width() as f64, } } @@ -296,7 +311,6 @@ impl Image { match &self.0.kind { ImageKind::Raster(raster) => raster.height() as f64, ImageKind::Svg(svg) => svg.height(), - ImageKind::Pixmap(pixmap) => pixmap.height() as f64, } } @@ -305,7 +319,6 @@ impl Image { match &self.0.kind { ImageKind::Raster(raster) => raster.dpi(), ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI), - ImageKind::Pixmap(_) => None, } } @@ -337,40 +350,6 @@ impl Debug for Image { } } -/// Information specifying the source of an image's byte data. -#[derive(Debug, Clone, PartialEq, Hash)] -pub enum ImageSource { - Data(DataSource), - Pixmap(PixmapSource), -} - -impl From for ImageSource { - fn from(bytes: Bytes) -> Self { - ImageSource::Data(DataSource::Bytes(bytes)) - } -} - -impl Load for Spanned { - type Output = Bytes; - - fn load(&self, world: Tracked) -> SourceResult { - match &self.v { - ImageSource::Data(data) => Spanned::new(data, self.span).load(world), - ImageSource::Pixmap(pixmap) => Ok(pixmap.data.clone()), - } - } -} - -cast! { - ImageSource, - self => match self { - Self::Data(data) => data.into_value(), - Self::Pixmap(pixmap) => pixmap.into_value(), - }, - data: DataSource => Self::Data(data), - pixmap: PixmapSource => Self::Pixmap(pixmap), -} - /// A kind of image. #[derive(Clone, Hash)] pub enum ImageKind { @@ -378,8 +357,6 @@ pub enum ImageKind { Raster(RasterImage), /// An SVG image. Svg(SvgImage), - /// An image constructed from a pixmap. - Pixmap(PixmapImage), } impl From for ImageKind { @@ -394,12 +371,6 @@ impl From for ImageKind { } } -impl From for ImageKind { - fn from(image: PixmapImage) -> Self { - Self::Pixmap(image) - } -} - /// A raster or vector image format. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum ImageFormat { @@ -407,15 +378,13 @@ pub enum ImageFormat { Raster(RasterFormat), /// A vector graphics format. Vector(VectorFormat), - /// A format made up of flat pixels without metadata or compression. - Pixmap(PixmapFormat), } impl ImageFormat { /// Try to detect the format of an image from data. pub fn detect(data: &[u8]) -> Option { - 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. @@ -434,9 +403,12 @@ pub enum VectorFormat { Svg, } -impl From for ImageFormat { - fn from(format: RasterFormat) -> Self { - Self::Raster(format) +impl From for ImageFormat +where + R: Into, +{ + fn from(format: R) -> Self { + Self::Raster(format.into()) } } @@ -446,22 +418,14 @@ impl From for ImageFormat { } } -impl From for ImageFormat { - fn from(format: PixmapFormat) -> Self { - Self::Pixmap(format) - } -} - cast! { ImageFormat, self => match self { Self::Raster(v) => v.into_value(), Self::Vector(v) => v.into_value(), - Self::Pixmap(v) => v.into_value(), }, v: RasterFormat => Self::Raster(v), v: VectorFormat => Self::Vector(v), - v: PixmapFormat => Self::Pixmap(v), } /// The image scaling algorithm a viewer should use. diff --git a/crates/typst-library/src/visualize/image/pixmap.rs b/crates/typst-library/src/visualize/image/pixmap.rs deleted file mode 100644 index f8154c497..000000000 --- a/crates/typst-library/src/visualize/image/pixmap.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::sync::Arc; - -use image::{DynamicImage, ImageBuffer, Pixel}; - -use crate::diag::{bail, StrResult}; -use crate::foundations::{cast, dict, Bytes, Cast, Dict}; - -/// A raster image based on a flat pixmap. -#[derive(Clone, Hash)] -pub struct PixmapImage(Arc); - -/// The internal representation. -#[derive(Hash)] -struct Repr { - source: PixmapSource, - format: PixmapFormat, -} - -impl PixmapImage { - /// Builds a new [`PixmapImage`] from a flat, uncompressed byte sequence. - #[comemo::memoize] - #[typst_macros::time(name = "load pixmap")] - pub fn new(source: PixmapSource, format: PixmapFormat) -> StrResult { - if source.pixel_width == 0 || source.pixel_height == 0 { - bail!("zero-sized images are not allowed"); - } - - let pixel_size = match format { - PixmapFormat::Rgb8 => 3, - PixmapFormat::Rgba8 => 4, - PixmapFormat::Luma8 => 1, - PixmapFormat::Lumaa8 => 2, - }; - - let Some(expected_size) = source - .pixel_width - .checked_mul(source.pixel_height) - .and_then(|size| size.checked_mul(pixel_size)) - else { - bail!("pixel dimensions are too large"); - }; - - if expected_size as usize != source.data.len() { - bail!("pixel dimensions and pixel data do not match"); - } - - Ok(Self(Arc::new(Repr { source, format }))) - } - - /// The image's format. - pub fn format(&self) -> PixmapFormat { - self.0.format - } - - /// The image's pixel width. - pub fn width(&self) -> u32 { - self.0.source.pixel_width - } - - /// The image's pixel height. - pub fn height(&self) -> u32 { - self.0.source.pixel_height - } - - /// The raw data encoded in the given format. - pub fn data(&self) -> &Bytes { - &self.0.source.data - } - - /// Transform the image data to a [`DynamicImage`]. - #[comemo::memoize] - pub fn to_dynamic(&self) -> Arc { - // TODO: Optimize by returning a `View` if possible? - fn decode>( - source: &PixmapSource, - ) -> ImageBuffer> { - ImageBuffer::from_raw( - source.pixel_width, - source.pixel_height, - source.data.to_vec(), - ) - .unwrap() - } - Arc::new(match self.0.format { - PixmapFormat::Rgb8 => decode::>(&self.0.source).into(), - PixmapFormat::Rgba8 => decode::>(&self.0.source).into(), - PixmapFormat::Luma8 => decode::>(&self.0.source).into(), - PixmapFormat::Lumaa8 => decode::>(&self.0.source).into(), - }) - } - - /// Access the ICC profile, if any. - pub fn icc_profile(&self) -> Option<&[u8]> { - self.0.source.icc_profile.as_deref() - } -} - -/// Determines how the given image is interpreted and encoded. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum PixmapFormat { - /// Raw image data with three 8-bit channels: Red, green, blue. - Rgb8, - /// Raw image data with four 8-bit channels: Red, green, blue, alpha. - Rgba8, - /// Raw image data with one 8-bit channel: Brightness. - Luma8, - /// Raw image data with two 8-bit channels: Brightness and alpha. - Lumaa8, -} - -/// Raw pixmap data and relevant metadata. -#[derive(Debug, Clone, PartialEq, Hash)] -pub struct PixmapSource { - pub data: Bytes, - pub pixel_width: u32, - pub pixel_height: u32, - pub icc_profile: Option, -} - -cast! { - PixmapSource, - self => dict! { - "data" => self.data.clone(), - "pixel-width" => self.pixel_width, - "pixel-height" => self.pixel_height, - "icc-profile" => self.icc_profile.clone() - }.into_value(), - mut dict: Dict => { - let source = PixmapSource { - data: dict.take("data")?.cast()?, - pixel_width: dict.take("pixel-width")?.cast()?, - pixel_height: dict.take("pixel-height")?.cast()?, - icc_profile: dict.take("icc-profile").ok().map(|v| v.cast()).transpose()?, - }; - dict.finish(&["data", "pixel-width", "pixel-height", "icc-profile"])?; - source - } -} diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 08fd11c4a..8f24a9fed 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, Value}; /// A decoded raster image. #[derive(Clone, Hash)] @@ -21,46 +23,111 @@ struct Repr { data: Bytes, format: RasterFormat, dynamic: image::DynamicImage, - icc_profile: Option>, + icc: Option>, dpi: Option, } impl RasterImage { /// Decode a raster image. + pub fn new(data: Bytes, format: impl Into) -> StrResult { + Self::new_impl(data, format.into()) + } + + /// The internal, non-generic implementation. #[comemo::memoize] #[typst_macros::time(name = "load raster image")] - pub fn new(data: Bytes, format: RasterFormat) -> StrResult { - fn decode_with( - decoder: ImageResult, - ) -> ImageResult<(image::DynamicImage, Option>)> { - 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)) - } + fn new_impl(data: Bytes, format: RasterFormat) -> StrResult { + let (dynamic, icc, dpi) = match format { + RasterFormat::Exchange(format) => { + fn decode_with( + decoder: ImageResult, + ) -> ImageResult<(image::DynamicImage, Option>)> { + 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_profile) = 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 cursor = io::Cursor::new(&data); + let (mut dynamic, icc) = match format { + ExchangeFormat::Jpg => decode_with(JpegDecoder::new(cursor)), + ExchangeFormat::Png => decode_with(PngDecoder::new(cursor)), + ExchangeFormat::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(); + 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); - } + // 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()); + // Extract pixel density. + let dpi = determine_dpi(&data, exif.as_ref()); - Ok(Self(Arc::new(Repr { data, format, dynamic, icc_profile, dpi }))) + (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 cast_as>( + data: &Bytes, + format: PixelFormat, + ) -> ImageBuffer> { + ImageBuffer::from_raw(format.width, format.height, data.to_vec()) + .unwrap() + } + + let dynamic = match format.encoding { + PixelEncoding::Rgb8 => { + cast_as::>(&data, format).into() + } + PixelEncoding::Rgba8 => { + cast_as::>(&data, format).into() + } + PixelEncoding::Luma8 => { + cast_as::>(&data, format).into() + } + PixelEncoding::Lumaa8 => { + cast_as::>(&data, format).into() + } + }; + + (dynamic, None, None) + } + }; + + Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) } /// The raw image data. @@ -94,8 +161,8 @@ impl RasterImage { } /// Access the ICC profile, if any. - pub fn icc_profile(&self) -> Option<&[u8]> { - self.0.icc_profile.as_deref() + pub fn icc(&self) -> Option<&[u8]> { + self.0.icc.as_deref() } } @@ -108,8 +175,39 @@ impl Hash for Repr { } /// A raster graphics format. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum RasterFormat { + /// A format used in image exchange. + Exchange(ExchangeFormat), + /// A format of raw pixel data. + Pixel(PixelFormat), +} + +impl From for RasterFormat { + fn from(format: ExchangeFormat) -> Self { + Self::Exchange(format) + } +} + +impl From 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), +} + +/// An 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. @@ -118,36 +216,84 @@ pub enum RasterFormat { Gif, } -impl RasterFormat { +impl ExchangeFormat { /// Try to detect the format of data in a buffer. pub fn detect(data: &[u8]) -> Option { guess_format(data).ok().and_then(|format| format.try_into().ok()) } } -impl From for image::ImageFormat { - fn from(format: RasterFormat) -> Self { +impl From 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 for RasterFormat { +impl TryFrom for ExchangeFormat { type Error = EcoString; fn try_from(format: image::ImageFormat) -> StrResult { 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 { + /// Raw image data with three 8-bit channels: Red, green, blue. + Rgb8, + /// Raw image data with four 8-bit channels: Red, green, blue, alpha. + Rgba8, + /// Raw image data with one 8-bit channel: Brightness. + Luma8, + /// Raw image data with two 8-bit channels: Brightness 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 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 { exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)? @@ -267,21 +413,21 @@ fn format_image_error(error: image::ImageError) -> EcoString { #[cfg(test)] mod tests { - use super::{RasterFormat, RasterImage}; + use super::{ExchangeFormat, RasterImage}; use crate::foundations::Bytes; #[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(); 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-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index 1a045b468..c8dfb1dcc 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -7,7 +7,7 @@ use pdf_writer::{Chunk, Filter, Finish, Ref}; use typst_library::diag::{At, SourceResult, StrResult}; use typst_library::foundations::Smart; use typst_library::visualize::{ - ColorSpace, Image, ImageKind, ImageScaling, RasterFormat, SvgImage, + ColorSpace, ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, SvgImage, }; use typst_utils::Deferred; @@ -126,12 +126,9 @@ pub fn deferred_image( pdfa: bool, ) -> (Deferred>, Option) { let color_space = match image.kind() { - ImageKind::Raster(raster) if raster.icc_profile().is_none() => { + ImageKind::Raster(raster) if raster.icc().is_none() => { Some(to_color_space(raster.dynamic().color())) } - ImageKind::Pixmap(pixmap) if pixmap.icc_profile().is_none() => { - Some(to_color_space(pixmap.to_dynamic().color())) - } _ => None, }; @@ -141,29 +138,19 @@ pub fn deferred_image( let deferred = Deferred::new(move || match image.kind() { ImageKind::Raster(raster) => { - let format = if raster.format() == RasterFormat::Jpg { + let format = if raster.format() == RasterFormat::Exchange(ExchangeFormat::Jpg) + { EncodeFormat::DctDecode } else { EncodeFormat::Flate }; - Ok(encode_raster_image( - raster.dynamic(), - raster.icc_profile(), - format, - interpolate, - )) + Ok(encode_raster_image(raster.dynamic(), raster.icc(), format, interpolate)) } ImageKind::Svg(svg) => { let (chunk, id) = encode_svg(svg, pdfa) .map_err(|err| eco_format!("failed to convert SVG to PDF: {err}"))?; Ok(EncodedImage::Svg(chunk, id)) } - ImageKind::Pixmap(pixmap) => Ok(encode_raster_image( - &pixmap.to_dynamic(), - pixmap.icc_profile(), - EncodeFormat::Flate, - interpolate, - )), }); (deferred, color_space) diff --git a/crates/typst-render/src/image.rs b/crates/typst-render/src/image.rs index 02da5eddb..48ac6199a 100644 --- a/crates/typst-render/src/image.rs +++ b/crates/typst-render/src/image.rs @@ -64,9 +64,6 @@ fn build_texture(image: &Image, w: u32, h: u32) -> Option> { ImageKind::Raster(raster) => { scale_image(&mut texture, raster.dynamic(), image.scaling()) } - ImageKind::Pixmap(pixmap) => { - scale_image(&mut texture, &pixmap.to_dynamic(), image.scaling()) - } ImageKind::Svg(svg) => { let tree = svg.tree(); let ts = tiny_skia::Transform::from_scale( diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs index 6c90b115b..2fdb9f139 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -4,7 +4,8 @@ use image::{codecs::png::PngEncoder, ImageEncoder}; use typst_library::foundations::Smart; use typst_library::layout::{Abs, Axes}; use typst_library::visualize::{ - Image, ImageFormat, ImageKind, ImageScaling, RasterFormat, VectorFormat, + ExchangeFormat, Image, ImageFormat, ImageKind, ImageScaling, RasterFormat, + VectorFormat, }; use crate::SVGRenderer; @@ -38,30 +39,32 @@ impl SVGRenderer { #[comemo::memoize] pub fn convert_image_to_base64_url(image: &Image) -> EcoString { let format = match image.format() { - ImageFormat::Raster(f) => match f { - RasterFormat::Png => "png", - RasterFormat::Jpg => "jpeg", - RasterFormat::Gif => "gif", + ImageFormat::Raster(RasterFormat::Exchange(f)) => match f { + ExchangeFormat::Png => "png", + ExchangeFormat::Jpg => "jpeg", + ExchangeFormat::Gif => "gif", }, + ImageFormat::Raster(RasterFormat::Pixel(_)) => "png", ImageFormat::Vector(f) => match f { VectorFormat::Svg => "svg+xml", }, - ImageFormat::Pixmap(_) => "png", }; let mut buf; let data = match image.kind() { - ImageKind::Raster(raster) => raster.data(), - ImageKind::Svg(svg) => svg.data(), - ImageKind::Pixmap(pixmap) => { - buf = vec![]; - let mut encoder = PngEncoder::new(&mut buf); - if let Some(icc_profile) = pixmap.icc_profile() { - encoder.set_icc_profile(icc_profile.to_vec()).ok(); + ImageKind::Raster(raster) => match raster.format() { + RasterFormat::Exchange(_) => raster.data(), + RasterFormat::Pixel(_) => { + buf = vec![]; + let mut encoder = PngEncoder::new(&mut buf); + if let Some(icc_profile) = raster.icc() { + encoder.set_icc_profile(icc_profile.to_vec()).ok(); + } + raster.dynamic().write_with_encoder(encoder).unwrap(); + buf.as_slice() } - pixmap.to_dynamic().write_with_encoder(encoder).unwrap(); - buf.as_slice() - } + }, + ImageKind::Svg(svg) => svg.data(), }; let mut url = eco_format!("data:image/{format};base64,"); diff --git a/crates/typst-svg/src/text.rs b/crates/typst-svg/src/text.rs index 7b78e6de3..296812da1 100644 --- a/crates/typst-svg/src/text.rs +++ b/crates/typst-svg/src/text.rs @@ -7,7 +7,7 @@ use typst_library::foundations::{Bytes, Smart}; use typst_library::layout::{Abs, Point, Ratio, Size, Transform}; use typst_library::text::{Font, TextItem}; use typst_library::visualize::{ - FillRule, Image, Paint, RasterFormat, RasterImage, RelativeTo, + ExchangeFormat, FillRule, Image, Paint, RasterImage, RelativeTo, }; use typst_utils::hash128; @@ -247,7 +247,7 @@ fn convert_bitmap_glyph_to_image(font: &Font, id: GlyphId) -> Option<(Image, f64 return None; } let image = Image::new( - RasterImage::new(Bytes::new(raster.data.to_vec()), RasterFormat::Png).ok()?, + RasterImage::new(Bytes::new(raster.data.to_vec()), ExchangeFormat::Png).ok()?, None, Smart::Auto, ); diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 9551ad047..6f6e1a157 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -67,71 +67,72 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- image-pixmap-rgb8 --- #image( - ( - data: bytes(( - 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, - 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, - 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80, - )), - pixel-width: 3, - pixel-height: 3, + bytes(( + 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, + 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, + 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80, + )), + format: ( + encoding: "rgb8", + width: 3, + height: 3, ), - format: "rgb8", width: 1cm, ) --- image-pixmap-rgba8 --- #image( - ( - data: bytes(( - 0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, - 0xFF, 0x00, 0x00, 0x80, 0x00, 0xFF, 0x00, 0x80, 0x00, 0x00, 0xFF, 0x80, - 0xFF, 0x00, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x10, 0x00, 0x00, 0xFF, 0x10, - )), - pixel-width: 3, - pixel-height: 3, + bytes(( + 0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0x00, 0x00, 0x80, 0x00, 0xFF, 0x00, 0x80, 0x00, 0x00, 0xFF, 0x80, + 0xFF, 0x00, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x10, 0x00, 0x00, 0xFF, 0x10, + )), + format: ( + encoding: "rgba8", + width: 3, + height: 3, ), - format: "rgba8", width: 1cm, ) --- image-pixmap-luma8 --- #image( - ( - data: bytes(range(16).map(x => x * 16)), - pixel-width: 4, - pixel-height: 4, + bytes(range(16).map(x => x * 16)), + format: ( + encoding: "luma8", + width: 4, + height: 4, ), - format: "luma8", width: 1cm, ) --- image-pixmap-lumaa8 --- #image( - ( - data: bytes(range(16).map(x => (0x80, x * 16)).flatten()), - pixel-width: 4, - pixel-height: 4, + bytes(range(16).map(x => (0x80, x * 16)).flatten()), + format: ( + encoding: "lumaa8", + width: 4, + height: 4, ), - format: "lumaa8", width: 1cm, ) --- image-scaling-methods --- #let img(scaling) = image( - ( - data: bytes(( - 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, - 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, - 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80, - )), - pixel-width: 3, - pixel-height: 3, + bytes(( + 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, + 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, + 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80, + )), + format: ( + encoding: "rgb8", + width: 3, + height: 3, ), - format: "rgb8", width: 1cm, scaling: scaling, ) + #stack( dir: ltr, spacing: 4pt, @@ -181,52 +182,52 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- image-pixmap-empty --- // Error: 1:2-8:2 zero-sized images are not allowed #image( - ( - data: bytes(()), - pixel-width: 0, - pixel-height: 0, + bytes(()), + format: ( + encoding: "rgb8", + width: 0, + height: 0, ), - format: "rgb8", ) --- image-pixmap-invalid-size --- // Error: 1:2-8:2 pixel dimensions and pixel data do not match #image( - ( - data: bytes((0x00, 0x00, 0x00)), - pixel-width: 16, - pixel-height: 16, + bytes((0x00, 0x00, 0x00)), + format: ( + encoding: "rgb8", + width: 16, + height: 16, ), - format: "rgb8", ) --- image-pixmap-unknown-attribute --- -// Error: 2:3-7:4 unexpected key "stowaway", valid keys are "data", "pixel-width", "pixel-height", and "icc-profile" #image( - ( - data: bytes((0x00, 0x00, 0x00)), - pixel-width: 1, - pixel-height: 1, + bytes((0x00, 0x00, 0x00)), + // Error: 1:11-6:4 unexpected key "stowaway", valid keys are "encoding", "width", and "height" + format: ( + encoding: "rgb8", + width: 1, + height: 1, stowaway: "I do work here, promise", ), - format: "rgb8", ) --- image-pixmap-but-png-format --- -// Error: 1:2-8:2 expected readable source for the given format (str or bytes) #image( - ( - data: bytes((0x00, 0x00, 0x00)), - pixel-width: 1, - pixel-height: 1, + bytes((0x00, 0x00, 0x00)), + // Error: 1:11-5:4 expected "rgb8", "rgba8", "luma8", or "lumaa8" + format: ( + encoding: "png", + width: 1, + height: 1, ), - format: "png", ) --- image-png-but-pixmap-format --- -// Error: 1:2-4:2 source must be a pixmap #image( read("/assets/images/tiger.jpg", encoding: none), + // Error: 11-18 expected "png", "jpg", "gif", dictionary, "svg", or auto format: "rgba8", )