diff --git a/Cargo.lock b/Cargo.lock index 3343c246b..ada3a3d4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,9 +1122,9 @@ checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" [[package]] name = "image" -version = "0.25.2" +version = "0.25.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" dependencies = [ "bytemuck", "byteorder-lite", @@ -3036,6 +3036,7 @@ dependencies = [ "comemo", "ecow", "flate2", + "image", "ttf-parser", "typst-library", "typst-macros", diff --git a/Cargo.toml b/Cargo.toml index 6b592cd39..d03bfa6d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ icu_provider_adapters = "1.4" icu_provider_blob = "1.4" icu_segmenter = { version = "1.4", features = ["serde"] } if_chain = "1" -image = { version = "0.25.2", default-features = false, features = ["png", "jpeg", "gif"] } +image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.5" kurbo = "0.11" diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index e521b993f..503c30820 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -10,7 +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, RasterFormat, VectorFormat, + Curve, ExchangeFormat, Image, ImageElem, ImageFit, ImageFormat, ImageKind, + RasterImage, SvgImage, VectorFormat, }; /// Layout the image. @@ -49,15 +50,27 @@ pub fn layout_image( } // Construct the image itself. - let image = Image::with_fonts( - data.clone(), - format, - elem.alt(styles), - engine.world, - &families(styles).map(|f| f.as_str()).collect::>(), - elem.flatten_text(styles), - ) - .at(span)?; + let kind = match format { + ImageFormat::Raster(format) => ImageKind::Raster( + RasterImage::new( + data.clone(), + format, + elem.icc(styles).as_ref().map(|icc| icc.derived.clone()), + ) + .at(span)?, + ), + ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( + SvgImage::with_fonts( + data.clone(), + engine.world, + elem.flatten_text(styles), + &families(styles).map(|f| f.as_str()).collect::>(), + ) + .at(span)?, + ), + }; + + let image = Image::new(kind, elem.alt(styles), elem.scaling(styles)); // Determine the image's pixel aspect ratio. let pxw = image.width(); @@ -129,10 +142,10 @@ fn determine_format(source: &DataSource, data: &Bytes) -> StrResult .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)), + "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 e3183e885..0a7b13c97 100644 --- a/crates/typst-library/src/text/font/color.rs +++ b/crates/typst-library/src/text/font/color.rs @@ -10,7 +10,9 @@ use xmlwriter::XmlWriter; use crate::foundations::Bytes; use crate::layout::{Abs, Frame, FrameItem, Point, Size}; use crate::text::{Font, Glyph}; -use crate::visualize::{FixedStroke, Geometry, Image, RasterFormat, VectorFormat}; +use crate::visualize::{ + ExchangeFormat, FixedStroke, Geometry, Image, RasterImage, SvgImage, +}; /// Whether this glyph should be rendered via simple outlining instead of via /// `glyph_frame`. @@ -102,12 +104,8 @@ fn draw_raster_glyph( upem: Abs, raster_image: ttf_parser::RasterGlyphImage, ) -> Option<()> { - let image = Image::new( - Bytes::new(raster_image.data.to_vec()), - RasterFormat::Png.into(), - None, - ) - .ok()?; + let data = Bytes::new(raster_image.data.to_vec()); + let image = Image::plain(RasterImage::plain(data, ExchangeFormat::Png).ok()?); // Apple Color emoji doesn't provide offset information (or at least // not in a way ttf-parser understands), so we artificially shift their @@ -178,9 +176,8 @@ fn draw_colr_glyph( ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter)?; svg.end_element(); - let data = svg.end_document().into_bytes(); - - let image = Image::new(Bytes::new(data), VectorFormat::Svg.into(), None).ok()?; + let data = Bytes::from_string(svg.end_document()); + let image = Image::plain(SvgImage::new(data).ok()?); let y_shift = Abs::pt(upem.to_pt() - y_max); let position = Point::new(Abs::pt(x_min), y_shift); @@ -255,9 +252,8 @@ fn draw_svg_glyph( ty = -top, ); - let image = - Image::new(Bytes::new(wrapper_svg.into_bytes()), VectorFormat::Svg.into(), None) - .ok()?; + let data = Bytes::from_string(wrapper_svg); + let image = Image::plain(SvgImage::new(data).ok()?); let position = Point::new(Abs::pt(left), Abs::pt(top) + upem); let size = Size::new(Abs::pt(width), Abs::pt(height)); diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 77f8426e4..0e5c9e329 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, - /// The image's format. Detected automatically by default. + /// The image's format. /// - /// 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). + /// 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, + /// ) + /// + /// #image( + /// bytes(range(16).map(x => x * 16)), + /// format: ( + /// encoding: "luma8", + /// width: 4, + /// height: 4, + /// ), + /// width: 2cm, + /// ) + /// ``` pub format: Smart, /// 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, + + /// 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::>>("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>, + /// 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, + /// A hint to viewers how they should scale the image. + #[named] + scaling: Option>, + /// Whether text in SVG images should be converted into curves before + /// embedding. + #[named] + flatten_text: Option, ) -> StrResult { 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, -} - -/// 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, } 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, alt: Option, - ) -> StrResult { - 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, + ) -> Self { + Self::new_impl(kind.into(), alt, scaling) } - /// Create a possibly font-dependent image from a buffer and a format. + /// Create an image with optional properties set to the default. + pub fn plain(kind: impl Into) -> Self { + Self::new(kind, None, Smart::Auto) + } + + /// The internal, non-generic implementation. This is memoized to reuse + /// the `Arc` and `LazyHash`. #[comemo::memoize] - #[typst_macros::time(name = "load image")] - pub fn with_fonts( - data: Bytes, - format: ImageFormat, + fn new_impl( + kind: ImageKind, alt: Option, - world: Tracked, - families: &[&str], - flatten_text: bool, - ) -> StrResult { - 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 })))) - } - - /// The raw image data. - pub fn data(&self) -> &Bytes { - match &self.0.kind { - ImageKind::Raster(raster) => raster.data(), - ImageKind::Svg(svg) => svg.data(), - } + scaling: Smart, + ) -> 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 { + 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 for ImageKind { + fn from(image: RasterImage) -> Self { + Self::Raster(image) + } +} + +impl From 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 { - 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 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()) } } @@ -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 098843a25..d43b15486 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>, + icc: Option, dpi: Option, } impl RasterImage { /// Decode a raster image. + pub fn new( + data: Bytes, + format: impl Into, + icc: Smart, + ) -> StrResult { + Self::new_impl(data, format.into(), icc) + } + + /// Create a raster image with optional properties set to the default. + pub fn plain(data: Bytes, format: impl Into) -> StrResult { + Self::new(data, format, Smart::Auto) + } + + /// The internal, non-generic implementation. #[comemo::memoize] - 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)) - } + #[typst_macros::time(name = "load raster image")] + fn new_impl( + data: Bytes, + format: RasterFormat, + icc: Smart, + ) -> StrResult { + let (dynamic, icc, dpi) = match format { + RasterFormat::Exchange(format) => { + fn decode( + decoder: ImageResult, + icc: Smart, + ) -> ImageResult<(image::DynamicImage, Option)> { + 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 { - 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(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(); + 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()); + + (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>( + data: &Bytes, + format: PixelFormat, + ) -> ImageBuffer> { + ImageBuffer::from_raw(format.width, format.height, data.to_vec()) + .unwrap() + } + + let dynamic = match format.encoding { + PixelEncoding::Rgb8 => to::>(&data, format).into(), + PixelEncoding::Rgba8 => to::>(&data, format).into(), + PixelEncoding::Luma8 => to::>(&data, format).into(), + PixelEncoding::Lumaa8 => to::>(&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(&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 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), +} + +/// 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 { 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 { + /// 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 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)? @@ -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 089f05430..dcc55077b 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 { 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, diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index bff7bfefa..550f60a4b 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,8 +5,10 @@ use ecow::eco_format; use image::{DynamicImage, GenericImageView, Rgba}; 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, RasterFormat, RasterImage, SvgImage, + ColorSpace, ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, + RasterImage, SvgImage, }; use typst_utils::Deferred; @@ -32,11 +34,13 @@ pub fn write_images( EncodedImage::Raster { data, filter, - has_color, + color_space, + bits_per_component, width, height, - icc, + compressed_icc, alpha, + interpolate, } => { let image_ref = chunk.alloc(); out.insert(image.clone(), image_ref); @@ -45,23 +49,18 @@ pub fn write_images( image.filter(*filter); image.width(*width as i32); image.height(*height as i32); - image.bits_per_component(8); + image.bits_per_component(i32::from(*bits_per_component)); + image.interpolate(*interpolate); let mut icc_ref = None; let space = image.color_space(); - if icc.is_some() { + if compressed_icc.is_some() { let id = chunk.alloc.bump(); space.icc_based(id); icc_ref = Some(id); - } else if *has_color { - color::write( - ColorSpace::Srgb, - space, - &context.globals.color_functions, - ); } else { color::write( - ColorSpace::D65Gray, + *color_space, space, &context.globals.color_functions, ); @@ -79,20 +78,27 @@ pub fn write_images( mask.width(*width as i32); mask.height(*height as i32); mask.color_space().device_gray(); - mask.bits_per_component(8); + mask.bits_per_component(i32::from(*bits_per_component)); + mask.interpolate(*interpolate); } else { image.finish(); } - if let (Some(icc), Some(icc_ref)) = (icc, icc_ref) { - let mut stream = chunk.icc_profile(icc_ref, icc); + if let (Some(compressed_icc), Some(icc_ref)) = + (compressed_icc, icc_ref) + { + let mut stream = chunk.icc_profile(icc_ref, compressed_icc); stream.filter(Filter::FlateDecode); - if *has_color { - stream.n(3); - stream.alternate().srgb(); - } else { - stream.n(1); - stream.alternate().d65_gray(); + match color_space { + ColorSpace::Srgb => { + stream.n(3); + stream.alternate().srgb(); + } + ColorSpace::D65Gray => { + stream.n(1); + stream.alternate().d65_gray(); + } + _ => unimplemented!(), } } } @@ -122,35 +128,17 @@ pub fn deferred_image( ) -> (Deferred>, Option) { let color_space = match image.kind() { ImageKind::Raster(raster) if raster.icc().is_none() => { - if raster.dynamic().color().channel_count() > 2 { - Some(ColorSpace::Srgb) - } else { - Some(ColorSpace::D65Gray) - } + Some(to_color_space(raster.dynamic().color())) } _ => None, }; + // PDF/A does not appear to allow interpolation. + // See https://github.com/typst/typst/issues/2942. + let interpolate = !pdfa && image.scaling() == Smart::Custom(ImageScaling::Smooth); + let deferred = Deferred::new(move || match image.kind() { - ImageKind::Raster(raster) => { - let raster = raster.clone(); - let (width, height) = (raster.width(), raster.height()); - let (data, filter, has_color) = encode_raster_image(&raster); - let icc = raster.icc().map(deflate); - - let alpha = - raster.dynamic().color().has_alpha().then(|| encode_alpha(&raster)); - - Ok(EncodedImage::Raster { - data, - filter, - has_color, - width, - height, - icc, - alpha, - }) - } + ImageKind::Raster(raster) => Ok(encode_raster_image(raster, interpolate)), ImageKind::Svg(svg) => { let (chunk, id) = encode_svg(svg, pdfa) .map_err(|err| eco_format!("failed to convert SVG to PDF: {err}"))?; @@ -161,42 +149,51 @@ pub fn deferred_image( (deferred, color_space) } -/// Encode an image with a suitable filter and return the data, filter and -/// whether the image has color. -/// -/// Skips the alpha channel as that's encoded separately. +/// Encode an image with a suitable filter. #[typst_macros::time(name = "encode raster image")] -fn encode_raster_image(image: &RasterImage) -> (Vec, Filter, bool) { +fn encode_raster_image(image: &RasterImage, interpolate: bool) -> EncodedImage { let dynamic = image.dynamic(); - let channel_count = dynamic.color().channel_count(); - let has_color = channel_count > 2; + let color_space = to_color_space(dynamic.color()); - if image.format() == RasterFormat::Jpg { - let mut data = Cursor::new(vec![]); - dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap(); - (data.into_inner(), Filter::DctDecode, has_color) - } else { - // TODO: Encode flate streams with PNG-predictor? - let data = match (dynamic, channel_count) { - (DynamicImage::ImageLuma8(luma), _) => deflate(luma.as_raw()), - (DynamicImage::ImageRgb8(rgb), _) => deflate(rgb.as_raw()), - // Grayscale image - (_, 1 | 2) => deflate(dynamic.to_luma8().as_raw()), - // Anything else - _ => deflate(dynamic.to_rgb8().as_raw()), + let (filter, data, bits_per_component) = + if image.format() == RasterFormat::Exchange(ExchangeFormat::Jpg) { + let mut data = Cursor::new(vec![]); + dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap(); + (Filter::DctDecode, data.into_inner(), 8) + } else { + // TODO: Encode flate streams with PNG-predictor? + let (data, bits_per_component) = match (dynamic, color_space) { + // RGB image. + (DynamicImage::ImageRgb8(rgb), _) => (deflate(rgb.as_raw()), 8), + // Grayscale image + (DynamicImage::ImageLuma8(luma), _) => (deflate(luma.as_raw()), 8), + (_, ColorSpace::D65Gray) => (deflate(dynamic.to_luma8().as_raw()), 8), + // Anything else + _ => (deflate(dynamic.to_rgb8().as_raw()), 8), + }; + (Filter::FlateDecode, data, bits_per_component) }; - (data, Filter::FlateDecode, has_color) + + let compressed_icc = image.icc().map(|data| deflate(data)); + let alpha = dynamic.color().has_alpha().then(|| encode_alpha(dynamic)); + + EncodedImage::Raster { + data, + filter, + color_space, + bits_per_component, + width: image.width(), + height: image.height(), + compressed_icc, + alpha, + interpolate, } } /// Encode an image's alpha channel if present. #[typst_macros::time(name = "encode alpha")] -fn encode_alpha(raster: &RasterImage) -> (Vec, Filter) { - let pixels: Vec<_> = raster - .dynamic() - .pixels() - .map(|(_, _, Rgba([_, _, _, a]))| a) - .collect(); +fn encode_alpha(image: &DynamicImage) -> (Vec, Filter) { + let pixels: Vec<_> = image.pixels().map(|(_, _, Rgba([_, _, _, a]))| a).collect(); (deflate(&pixels), Filter::FlateDecode) } @@ -224,19 +221,33 @@ pub enum EncodedImage { data: Vec, /// The filter to use for the image. filter: Filter, - /// Whether the image has color. - has_color: bool, + /// Which color space this image is encoded in. + color_space: ColorSpace, + /// How many bits of each color component are stored. + bits_per_component: u8, /// The image's width. width: u32, /// The image's height. height: u32, - /// The image's ICC profile, pre-deflated, if any. - icc: Option>, + /// The image's ICC profile, deflated, if any. + compressed_icc: Option>, /// The alpha channel of the image, pre-deflated, if any. alpha: Option<(Vec, Filter)>, + /// Whether image interpolation should be enabled. + interpolate: bool, }, /// A vector graphic. /// /// The chunk is the SVG converted to PDF objects. Svg(Chunk, Ref), } + +/// Matches an [`image::ColorType`] to [`ColorSpace`]. +fn to_color_space(color: image::ColorType) -> ColorSpace { + use image::ColorType::*; + match color { + L8 | La8 | L16 | La16 => ColorSpace::D65Gray, + Rgb8 | Rgba8 | Rgb16 | Rgba16 | Rgb32F | Rgba32F => ColorSpace::Srgb, + _ => unimplemented!(), + } +} diff --git a/crates/typst-render/src/image.rs b/crates/typst-render/src/image.rs index 27b039113..7425bdd2f 100644 --- a/crates/typst-render/src/image.rs +++ b/crates/typst-render/src/image.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use image::imageops::FilterType; use image::{GenericImageView, Rgba}; use tiny_skia as sk; +use typst_library::foundations::Smart; use typst_library::layout::Size; -use typst_library::visualize::{Image, ImageKind}; +use typst_library::visualize::{Image, ImageKind, ImageScaling}; use crate::{AbsExt, State}; @@ -34,7 +35,7 @@ pub fn render_image( let w = (scale_x * view_width.max(aspect * view_height)).ceil() as u32; let h = ((w as f32) / aspect).ceil() as u32; - let pixmap = scaled_texture(image, w, h)?; + let pixmap = build_texture(image, w, h)?; let paint_scale_x = view_width / pixmap.width() as f32; let paint_scale_y = view_height / pixmap.height() as f32; @@ -57,29 +58,42 @@ pub fn render_image( /// Prepare a texture for an image at a scaled size. #[comemo::memoize] -fn scaled_texture(image: &Image, w: u32, h: u32) -> Option> { - let mut pixmap = sk::Pixmap::new(w, h)?; +fn build_texture(image: &Image, w: u32, h: u32) -> Option> { + let mut texture = sk::Pixmap::new(w, h)?; match image.kind() { ImageKind::Raster(raster) => { - let downscale = w < raster.width(); - let filter = - if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom }; - let buf = raster.dynamic().resize(w, h, filter); - for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) { + let w = texture.width(); + let h = texture.height(); + + let buf; + let dynamic = raster.dynamic(); + let resized = if (w, h) == (dynamic.width(), dynamic.height()) { + // Small optimization to not allocate in case image is not resized. + dynamic + } else { + let upscale = w > dynamic.width(); + let filter = match image.scaling() { + Smart::Custom(ImageScaling::Pixelated) => FilterType::Nearest, + _ if upscale => FilterType::CatmullRom, + _ => FilterType::Lanczos3, // downscale + }; + buf = dynamic.resize_exact(w, h, filter); + &buf + }; + + for ((_, _, src), dest) in resized.pixels().zip(texture.pixels_mut()) { let Rgba([r, g, b, a]) = src; *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply(); } } - // Safety: We do not keep any references to tree nodes beyond the scope - // of `with`. ImageKind::Svg(svg) => { let tree = svg.tree(); let ts = tiny_skia::Transform::from_scale( w as f32 / tree.size().width(), h as f32 / tree.size().height(), ); - resvg::render(tree, ts, &mut pixmap.as_mut()) + resvg::render(tree, ts, &mut texture.as_mut()); } } - Some(Arc::new(pixmap)) + Some(Arc::new(texture)) } diff --git a/crates/typst-svg/Cargo.toml b/crates/typst-svg/Cargo.toml index 41d355659..5416621e5 100644 --- a/crates/typst-svg/Cargo.toml +++ b/crates/typst-svg/Cargo.toml @@ -21,6 +21,7 @@ base64 = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } flate2 = { workspace = true } +image = { workspace = true } ttf-parser = { workspace = true } xmlparser = { workspace = true } xmlwriter = { workspace = true } diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs index ede4e76e3..d74432026 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -1,7 +1,11 @@ use base64::Engine; use ecow::{eco_format, EcoString}; +use image::{codecs::png::PngEncoder, ImageEncoder}; +use typst_library::foundations::Smart; use typst_library::layout::{Abs, Axes}; -use typst_library::visualize::{Image, ImageFormat, RasterFormat, VectorFormat}; +use typst_library::visualize::{ + ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, +}; use crate::SVGRenderer; @@ -14,6 +18,17 @@ impl SVGRenderer { self.xml.write_attribute("width", &size.x.to_pt()); self.xml.write_attribute("height", &size.y.to_pt()); self.xml.write_attribute("preserveAspectRatio", "none"); + match image.scaling() { + Smart::Auto => {} + Smart::Custom(ImageScaling::Smooth) => { + // This is still experimental and not implemented in all major browsers. + // https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#browser_compatibility + self.xml.write_attribute("style", "image-rendering: smooth") + } + Smart::Custom(ImageScaling::Pixelated) => { + self.xml.write_attribute("style", "image-rendering: pixelated") + } + } self.xml.end_element(); } } @@ -22,19 +37,32 @@ impl SVGRenderer { /// `data:image/{format};base64,`. #[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::Vector(f) => match f { - VectorFormat::Svg => "svg+xml", + let mut buf; + let (format, data): (&str, &[u8]) = match image.kind() { + ImageKind::Raster(raster) => match raster.format() { + RasterFormat::Exchange(format) => ( + match format { + ExchangeFormat::Png => "png", + ExchangeFormat::Jpg => "jpeg", + ExchangeFormat::Gif => "gif", + }, + raster.data(), + ), + RasterFormat::Pixel(_) => ("png", { + 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() + }), }, + ImageKind::Svg(svg) => ("svg+xml", svg.data()), }; let mut url = eco_format!("data:image/{format};base64,"); - let data = base64::engine::general_purpose::STANDARD.encode(image.data()); + let data = base64::engine::general_purpose::STANDARD.encode(data); url.push_str(&data); url } diff --git a/crates/typst-svg/src/text.rs b/crates/typst-svg/src/text.rs index fa471b2ae..e6620a59e 100644 --- a/crates/typst-svg/src/text.rs +++ b/crates/typst-svg/src/text.rs @@ -6,7 +6,9 @@ use ttf_parser::GlyphId; use typst_library::foundations::Bytes; use typst_library::layout::{Abs, Point, Ratio, Size, Transform}; use typst_library::text::{Font, TextItem}; -use typst_library::visualize::{FillRule, Image, Paint, RasterFormat, RelativeTo}; +use typst_library::visualize::{ + ExchangeFormat, FillRule, Image, Paint, RasterImage, RelativeTo, +}; use typst_utils::hash128; use crate::{SVGRenderer, State, SvgMatrix, SvgPathBuilder}; @@ -244,9 +246,9 @@ fn convert_bitmap_glyph_to_image(font: &Font, id: GlyphId) -> Option<(Image, f64 if raster.format != ttf_parser::RasterImageFormat::PNG { return None; } - let image = - Image::new(Bytes::new(raster.data.to_vec()), RasterFormat::Png.into(), None) - .ok()?; + let image = Image::plain( + RasterImage::plain(Bytes::new(raster.data.to_vec()), ExchangeFormat::Png).ok()?, + ); Some((image, raster.x as f64, raster.y as f64)) } diff --git a/tests/ref/baseline-box.png b/tests/ref/baseline-box.png index 2a9e51758..e07e22ea7 100644 Binary files a/tests/ref/baseline-box.png and b/tests/ref/baseline-box.png differ diff --git a/tests/ref/box-clip-outset.png b/tests/ref/box-clip-outset.png index 21538e85f..8ea40e968 100644 Binary files a/tests/ref/box-clip-outset.png and b/tests/ref/box-clip-outset.png differ diff --git a/tests/ref/box-clip-radius-without-stroke.png b/tests/ref/box-clip-radius-without-stroke.png index 121373582..2be772cc5 100644 Binary files a/tests/ref/box-clip-radius-without-stroke.png and b/tests/ref/box-clip-radius-without-stroke.png differ diff --git a/tests/ref/box-clip-radius.png b/tests/ref/box-clip-radius.png index da20fa5bf..530d53309 100644 Binary files a/tests/ref/box-clip-radius.png and b/tests/ref/box-clip-radius.png differ diff --git a/tests/ref/closure-path-resolve-in-layout-phase.png b/tests/ref/closure-path-resolve-in-layout-phase.png index baaed3564..a3d699981 100644 Binary files a/tests/ref/closure-path-resolve-in-layout-phase.png and b/tests/ref/closure-path-resolve-in-layout-phase.png differ diff --git a/tests/ref/coma.png b/tests/ref/coma.png index 2c59ae870..a1d743a49 100644 Binary files a/tests/ref/coma.png and b/tests/ref/coma.png differ diff --git a/tests/ref/footnote-in-caption.png b/tests/ref/footnote-in-caption.png index 12a5fde5e..79b2b5d0f 100644 Binary files a/tests/ref/footnote-in-caption.png and b/tests/ref/footnote-in-caption.png differ diff --git a/tests/ref/footnote-in-table.png b/tests/ref/footnote-in-table.png index 062a6fc71..e110eac6d 100644 Binary files a/tests/ref/footnote-in-table.png and b/tests/ref/footnote-in-table.png differ diff --git a/tests/ref/image-baseline-with-box.png b/tests/ref/image-baseline-with-box.png index dc8e8bc57..ade90e2f5 100644 Binary files a/tests/ref/image-baseline-with-box.png and b/tests/ref/image-baseline-with-box.png differ diff --git a/tests/ref/image-decode-detect-format.png b/tests/ref/image-decode-detect-format.png index 6ecb7dcda..cee71bb93 100644 Binary files a/tests/ref/image-decode-detect-format.png and b/tests/ref/image-decode-detect-format.png differ diff --git a/tests/ref/image-decode-specify-format.png b/tests/ref/image-decode-specify-format.png index 6ecb7dcda..cee71bb93 100644 Binary files a/tests/ref/image-decode-specify-format.png and b/tests/ref/image-decode-specify-format.png differ diff --git a/tests/ref/image-fit.png b/tests/ref/image-fit.png index 24946fdbd..432daf741 100644 Binary files a/tests/ref/image-fit.png and b/tests/ref/image-fit.png differ diff --git a/tests/ref/image-pixmap-luma8.png b/tests/ref/image-pixmap-luma8.png new file mode 100644 index 000000000..cf5790cb7 Binary files /dev/null and b/tests/ref/image-pixmap-luma8.png differ diff --git a/tests/ref/image-pixmap-lumaa8.png b/tests/ref/image-pixmap-lumaa8.png new file mode 100644 index 000000000..b8eea6fd6 Binary files /dev/null and b/tests/ref/image-pixmap-lumaa8.png differ diff --git a/tests/ref/image-pixmap-rgb8.png b/tests/ref/image-pixmap-rgb8.png new file mode 100644 index 000000000..d905c1eee Binary files /dev/null and b/tests/ref/image-pixmap-rgb8.png differ diff --git a/tests/ref/image-pixmap-rgba8.png b/tests/ref/image-pixmap-rgba8.png new file mode 100644 index 000000000..b87a6e411 Binary files /dev/null and b/tests/ref/image-pixmap-rgba8.png differ diff --git a/tests/ref/image-scaling-methods.png b/tests/ref/image-scaling-methods.png new file mode 100644 index 000000000..9d543e114 Binary files /dev/null and b/tests/ref/image-scaling-methods.png differ diff --git a/tests/ref/image-sizing.png b/tests/ref/image-sizing.png index e1201cf22..7cb59fc76 100644 Binary files a/tests/ref/image-sizing.png and b/tests/ref/image-sizing.png differ diff --git a/tests/ref/issue-4361-transparency-leak.png b/tests/ref/issue-4361-transparency-leak.png index 4060d43ac..660798166 100644 Binary files a/tests/ref/issue-4361-transparency-leak.png and b/tests/ref/issue-4361-transparency-leak.png differ diff --git a/tests/ref/pad-followed-by-content.png b/tests/ref/pad-followed-by-content.png index 90b48232a..534a97870 100644 Binary files a/tests/ref/pad-followed-by-content.png and b/tests/ref/pad-followed-by-content.png differ diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 846650c68..6f6e1a157 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -65,6 +65,82 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B caption: [Bilingual text] ) +--- image-pixmap-rgb8 --- +#image( + 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, + ), + width: 1cm, +) + +--- image-pixmap-rgba8 --- +#image( + 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, + ), + width: 1cm, +) + +--- image-pixmap-luma8 --- +#image( + bytes(range(16).map(x => x * 16)), + format: ( + encoding: "luma8", + width: 4, + height: 4, + ), + width: 1cm, +) + +--- image-pixmap-lumaa8 --- +#image( + bytes(range(16).map(x => (0x80, x * 16)).flatten()), + format: ( + encoding: "lumaa8", + width: 4, + height: 4, + ), + width: 1cm, +) + +--- image-scaling-methods --- +#let img(scaling) = image( + 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, + ), + width: 1cm, + scaling: scaling, +) + +#stack( + dir: ltr, + spacing: 4pt, + img(auto), + img("smooth"), + img("pixelated"), +) + --- image-natural-dpi-sizing --- // Test that images aren't upscaled. // Image is just 48x80 at 220dpi. It should not be scaled to fit the page @@ -103,6 +179,58 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B // Error: 2-91 failed to decode image (Format error decoding Png: Invalid PNG signature.) #image.decode(read("/assets/images/tiger.jpg", encoding: none), format: "png", width: 80%) +--- image-pixmap-empty --- +// Error: 1:2-8:2 zero-sized images are not allowed +#image( + bytes(()), + format: ( + encoding: "rgb8", + width: 0, + height: 0, + ), +) + +--- image-pixmap-invalid-size --- +// Error: 1:2-8:2 pixel dimensions and pixel data do not match +#image( + bytes((0x00, 0x00, 0x00)), + format: ( + encoding: "rgb8", + width: 16, + height: 16, + ), +) + +--- image-pixmap-unknown-attribute --- +#image( + 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", + ), +) + +--- image-pixmap-but-png-format --- +#image( + bytes((0x00, 0x00, 0x00)), + // Error: 1:11-5:4 expected "rgb8", "rgba8", "luma8", or "lumaa8" + format: ( + encoding: "png", + width: 1, + height: 1, + ), +) + +--- image-png-but-pixmap-format --- +#image( + read("/assets/images/tiger.jpg", encoding: none), + // Error: 11-18 expected "png", "jpg", "gif", dictionary, "svg", or auto + format: "rgba8", +) + --- issue-870-image-rotation --- // Ensure that EXIF rotation is applied. // https://github.com/image-rs/image/issues/1045