diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index 79c2c73d6..6a64371e3 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -65,6 +65,7 @@ pub fn layout_image( world: Some(engine.world), families: &families(styles).map(|f| f.as_str()).collect::>(), flatten_text: elem.flatten_text(styles), + scaling: elem.scaling(styles), }, ) .at(span)?; diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 6688e2d68..37f42155f 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -20,8 +20,8 @@ use typst_utils::LazyHash; use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - cast, elem, func, scope, Bytes, Cast, Content, Dict, NativeElement, Packed, Show, - Smart, StyleChain, + cast, elem, func, scope, AutoValue, Bytes, Cast, Content, Dict, NativeElement, + Packed, Show, Smart, StyleChain, Value, }; use crate::layout::{BlockElem, Length, Rel, Sizing}; use crate::loading::Readable; @@ -103,6 +103,12 @@ pub struct ImageElem { /// output. #[default(false)] pub flatten_text: bool, + + /// A hint to the viewer how it should scale the image. + /// + /// **Note:** This option may be ignored and results look different + /// depending on the format and viewer. + pub scaling: ImageScaling, } #[scope] @@ -141,6 +147,12 @@ impl ImageElem { /// How the image should adjust itself to a given area. #[named] fit: Option, + /// Whether text in SVG images should be converted into paths. + #[named] + flatten_text: Option, + /// How the image should be scaled by the viewer. + #[named] + scaling: Option, ) -> StrResult { let mut elem = ImageElem::new(EcoString::new(), source); if let Some(format) = format { @@ -158,6 +170,15 @@ impl ImageElem { if let Some(fit) = fit { elem.push_fit(fit); } + if let Some(fit) = fit { + elem.push_fit(fit); + } + if let Some(flatten_text) = flatten_text { + elem.push_flatten_text(flatten_text); + } + if let Some(scaling) = scaling { + elem.push_scaling(scaling); + } Ok(elem.pack().spanned(span)) } } @@ -208,6 +229,8 @@ struct Repr { kind: ImageKind, /// A text describing the image. alt: Option, + /// The scaling algorithm to use. + scaling: ImageScaling, } impl Image { @@ -247,7 +270,11 @@ impl Image { } }; - Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt: options.alt.clone() })))) + Ok(Self(Arc::new(LazyHash::new(Repr { + kind, + alt: options.alt.clone(), + scaling: options.scaling, + })))) } /// The format of the image. @@ -291,6 +318,11 @@ impl Image { self.0.alt.as_deref() } + /// The image scaling algorithm to use for this image. + pub fn scaling(&self) -> ImageScaling { + self.0.scaling + } + /// The decoded image. pub fn kind(&self) -> &ImageKind { &self.0.kind @@ -304,6 +336,7 @@ impl Debug for Image { .field("width", &self.width()) .field("height", &self.height()) .field("alt", &self.alt()) + .field("scaling", &self.scaling()) .finish() } } @@ -384,6 +417,31 @@ cast! { v: PixmapFormat => Self::Pixmap(v), } +/// The image scaling algorithm a viewer should use. +#[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum ImageScaling { + /// Use the default scaling algorithm. + #[default] + Auto, + /// Scale photos with a smoothing algorithm such as bilinear interpolation. + Smooth, + /// Scale with nearest neighbor or similar to preserve the pixelated look + /// of the image. + Pixelated, +} + +cast! { + ImageScaling, + self => match self { + ImageScaling::Auto => Value::Auto, + ImageScaling::Pixelated => "pixelated".into_value(), + ImageScaling::Smooth => "smooth".into_value(), + }, + _: AutoValue => ImageScaling::Auto, + "pixelated" => ImageScaling::Pixelated, + "smooth" => ImageScaling::Smooth, +} + /// A kind of image. #[derive(Hash)] pub enum ImageKind { @@ -397,6 +455,7 @@ pub enum ImageKind { pub struct ImageOptions<'a> { pub alt: Option, + pub scaling: ImageScaling, pub world: Option>, pub families: &'a [&'a str], pub flatten_text: bool, @@ -406,6 +465,7 @@ impl Default for ImageOptions<'_> { fn default() -> Self { ImageOptions { alt: None, + scaling: ImageScaling::Auto, world: None, families: &[], flatten_text: false, @@ -416,6 +476,7 @@ impl Default for ImageOptions<'_> { impl Hash for ImageOptions<'_> { fn hash(&self, state: &mut H) { self.alt.hash(state); + self.scaling.hash(state); self.families.hash(state); self.flatten_text.hash(state); } diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index a912d68f4..4c599c4be 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,7 +5,9 @@ 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::visualize::{ColorSpace, Image, ImageKind, RasterFormat, SvgImage}; +use typst_library::visualize::{ + ColorSpace, Image, ImageKind, ImageScaling, RasterFormat, SvgImage, +}; use typst_utils::Deferred; use crate::{color, deflate, PdfChunk, WithGlobalRefs}; @@ -36,6 +38,7 @@ pub fn write_images( height, icc_profile, alpha, + interpolate, } => { let image_ref = chunk.alloc(); out.insert(image.clone(), image_ref); @@ -45,6 +48,7 @@ pub fn write_images( image.width(*width as i32); image.height(*height as i32); image.bits_per_component(i32::from(*bits_per_component)); + image.interpolate(*interpolate); let mut icc_ref = None; let space = image.color_space(); @@ -73,6 +77,7 @@ pub fn write_images( mask.height(*height as i32); mask.color_space().device_gray(); mask.bits_per_component(i32::from(*bits_per_component)); + mask.interpolate(*interpolate); } else { image.finish(); } @@ -127,6 +132,10 @@ pub fn deferred_image( _ => None, }; + // PDF/A does not appear to allow interpolation[^1]. + // [^1]: https://github.com/typst/typst/issues/2942 + let interpolate = image.scaling() == ImageScaling::Smooth && !pdfa; + let deferred = Deferred::new(move || match image.kind() { ImageKind::Raster(raster) => { let format = if raster.format() == RasterFormat::Jpg { @@ -134,7 +143,12 @@ pub fn deferred_image( } else { EncodeFormat::Flate }; - Ok(encode_raster_image(&raster.dynamic(), raster.icc_profile(), format)) + Ok(encode_raster_image( + raster.dynamic(), + raster.icc_profile(), + format, + interpolate, + )) } ImageKind::Svg(svg) => { let (chunk, id) = encode_svg(svg, pdfa) @@ -145,6 +159,7 @@ pub fn deferred_image( &pixmap.to_image(), pixmap.icc_profile(), EncodeFormat::Flate, + interpolate, )), }); @@ -157,6 +172,7 @@ fn encode_raster_image( image: &DynamicImage, icc_profile: Option<&[u8]>, format: EncodeFormat, + interpolate: bool, ) -> EncodedImage { let color_space = to_color_space(image.color()); @@ -192,6 +208,7 @@ fn encode_raster_image( height: image.height(), icc_profile: compressed_icc, alpha, + interpolate, } } @@ -248,6 +265,8 @@ pub enum EncodedImage { icc_profile: 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. /// diff --git a/crates/typst-render/src/image.rs b/crates/typst-render/src/image.rs index 5a487fbc0..2c9547f60 100644 --- a/crates/typst-render/src/image.rs +++ b/crates/typst-render/src/image.rs @@ -4,7 +4,7 @@ use image::imageops::FilterType; use image::{GenericImageView, Rgba}; use tiny_skia as sk; use typst_library::layout::Size; -use typst_library::visualize::{Image, ImageKind}; +use typst_library::visualize::{Image, ImageKind, ImageScaling}; use crate::{AbsExt, State}; @@ -59,8 +59,10 @@ pub fn render_image( #[comemo::memoize] fn build_texture(image: &Image, w: u32, h: u32) -> Option> { match image.kind() { - ImageKind::Raster(raster) => scale_image(raster.dynamic(), w, h), - ImageKind::Pixmap(raster) => scale_image(&raster.to_image(), w, h), + ImageKind::Raster(raster) => scale_image(raster.dynamic(), image.scaling(), w, h), + ImageKind::Pixmap(raster) => { + scale_image(&raster.to_image(), image.scaling(), w, h) + } // Safety: We do not keep any references to tree nodes beyond the scope // of `with`. ImageKind::Svg(svg) => { @@ -78,10 +80,20 @@ fn build_texture(image: &Image, w: u32, h: u32) -> Option> { /// Scale a rastered image to a given size and return texture. // TODO(frozolotl): optimize pixmap allocation -fn scale_image(image: &image::DynamicImage, w: u32, h: u32) -> Option> { +fn scale_image( + image: &image::DynamicImage, + scaling: ImageScaling, + w: u32, + h: u32, +) -> Option> { let mut pixmap = sk::Pixmap::new(w, h)?; - let downscale = w < image.width(); - let filter = if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom }; + let upscale = w > image.width(); + let filter = match scaling { + ImageScaling::Auto if upscale => FilterType::CatmullRom, + ImageScaling::Smooth if upscale => FilterType::CatmullRom, + ImageScaling::Pixelated => FilterType::Nearest, + _ => FilterType::Lanczos3, // downscale + }; let buf = image.resize(w, h, filter); for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) { let Rgba([r, g, b, a]) = src; diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs index 953630766..c5523b8b0 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -6,7 +6,7 @@ use image::error::UnsupportedError; use image::{codecs::png::PngEncoder, ImageEncoder}; use typst_library::layout::{Abs, Axes}; use typst_library::visualize::{ - Image, ImageFormat, ImageKind, RasterFormat, VectorFormat, + Image, ImageFormat, ImageKind, ImageScaling, RasterFormat, VectorFormat, }; use crate::SVGRenderer; @@ -20,6 +20,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() { + ImageScaling::Auto => {} + ImageScaling::Smooth => { + // This is still experimental and not implemented in all major browsers[^1]. + // [^1]: https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#browser_compatibility + self.xml.write_attribute("style", "image-rendering: smooth") + } + ImageScaling::Pixelated => { + self.xml.write_attribute("style", "image-rendering: pixelated") + } + } self.xml.end_element(); } } diff --git a/tests/ref/image-pixmap-rgb8.png b/tests/ref/image-pixmap-rgb8.png index 69db78177..d905c1eee 100644 Binary files a/tests/ref/image-pixmap-rgb8.png and b/tests/ref/image-pixmap-rgb8.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/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 1ff68589e..80e48f940 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -71,7 +71,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B data: bytes(( 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, - 0x80, 0x80, 0x00, 0x80, 0x80, 0x00, 0x80, 0x00, 0x80, + 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80, )), pixel-width: 3, pixel-height: 3, @@ -117,6 +117,29 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B width: 1cm, ) +--- image-scaling-methods --- +#let img(scaling) = image.decode( + ( + 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, + ), + format: "rgb8", + 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