From 9789fdb86070a9596a4f5baf86b8a978096a0347 Mon Sep 17 00:00:00 2001 From: frozolotl Date: Fri, 13 Dec 2024 18:35:07 +0100 Subject: [PATCH] feat: allow user to choose how images should be scaled Closes #1400. --- crates/typst-layout/src/image.rs | 1 + .../typst-library/src/visualize/image/mod.rs | 67 +++++++++++++++++- crates/typst-pdf/src/image.rs | 23 +++++- crates/typst-render/src/image.rs | 24 +++++-- crates/typst-svg/src/image.rs | 13 +++- tests/ref/image-pixmap-rgb8.png | Bin 1085 -> 1220 bytes tests/ref/image-scaling-methods.png | Bin 0 -> 1539 bytes tests/suite/visualize/image.typ | 25 ++++++- 8 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 tests/ref/image-scaling-methods.png 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 69db781773b7f51bb672d46db03709c4bbd4a959..d905c1eee9a68a63c5f9908784f60cb451608c75 100644 GIT binary patch delta 1200 zcmV;h1W)_D2*e4HB!7!ZL_t(|+U%6gO&mcK1`A&~5`qID1|lE{A|ML_AP#O^$pMju zcXs~!@AdO`w`S3zm8|u_lEwQ}>Pt;uYNSSO|72JK3vHoo7TQ8vXq$z$&^8PGzlMer zuG#(M|_47n&ei<;y}VcrAoX6jDMWIiLYN#CnFKjx>$8m->Rx3d;dy%{U>%Lh^`1*8&U0 zt%6U*#ejISLx0OXYyIcY0uIK?EG)tr3&9Z`vXCcqf`Tw7mWT@kPh5*;+HQLg3w?fj zt`78YU^tK&6LVzsI7e7WGcuw>`$Cu!ODNi}#08nq5t`mZUr84F>pB`&UF#$z^cYz@ z>q?kOBY8l3Cx4R6SL<-{=ll=rh}GbS;(A3x88&uA%)*9FZO^4*E>r{(~zz zOr!yt+wn=X&|mlKGxSxrJr84I4yuZegnBU)yj=rLI@<unMdZsS}%hwIoD(^167y>!nF#T@Gkme z`~eqSgM?>%Og?Enq}tvTtCn4|lkp*#Qa$846(5S{LOOur4WSBv=PvqlBt(mVLIM*h z%717~nN6nK;2M@0)JOAi@NsNVM$h+1dgodUy67FzD!8B;B$2FAjIj_hy-aQsI&u@u zkSPOYq8!MLBbreS-4f(3`g76)75vRLVk+6B6oxTm-jVKH4%9)kBq>HN*od{FaYPG} z0znk|ir$GHL2yEJLq(H{CCM2Z$dlWh>wi6dfKjAqE5wTEh)(Diksm^|g=husqhmMe z7lajr8k$s-YL+yjcjys0I!CYfFo|YcEz1O7#Ehq@dJ<7_=_0oqrcF zJb{Ar6}=PtfRI)^33_v#5ObpQueEwoR9zB{=x~FTE_y%dJxaVv!yBRv8c_s_ueBmK z61>N|=-qxp-rfYVq2CsQZbh(tZ^ISnGx~FW|LqUJH!8ZIGj`)jo*Qn}&8te4G>grC zyV|hDhf7V4KmhRY>(6|D=}(jP$0>HZ=$tBg3{1xcYgTS-BkvgY!I_MSZjSSY;1jaC=6JfIXo7s=Rnl*uLM(IqD{2L zM4M<6Z86a%+G3*rucG0M=Uo5Bw>Nx#$Hx!6|HRv`yuRh-(tm!*?Q6ci<B zUTTxf@}oruh&qu3Nu-2CG)&~SPDCN4tFyUXn%F-F2j8jv1sp*7e3XZG>{hs-f?O0nt83sZ}I-+~nQ+_EEP4wOT$lMfe6Z~ z&VGMTFwqxsy^>w)WH+%+Fn=3jjfJ{ z55uAu4u6i5qgDFE7>EPX!3lqa102d>*ayZwABHU~J=hnLiN2c?w1p1(nxSU&L`aGz ze4rYrJD5;*?o(gK=DiI>+R3)ee zA&O7pBzcJyp*&833cN>6^xec~h@VJVv~18FZhwd0Qa3a;J5K_H4aA5)(H0YJq9IMR iDKOC{+G3*rv-t}Ves{4iHRj|10000 diff --git a/tests/ref/image-scaling-methods.png b/tests/ref/image-scaling-methods.png new file mode 100644 index 0000000000000000000000000000000000000000..9d543e114fc5578525babe90f004745905aa5e6e GIT binary patch literal 1539 zcmV+e2K@PnP)UFHXiK+?kG;y?sbQJH^V7ARUkluAmdP(cAuIIfWy_bbTKLc*K!L)Rs=?+E2rD;<7~xj9?1AV1*%M{74uPOX|=L zGD07*fzjg~VL)stOu>Qt{=W7{CoWL*nyVcf*sSjRBnd!iw2(G6sTZKU(q5ob%? zm2O?x??v`8C-2w#@PxTM(W^aG#9G-1U7#-_Tu(56+c&%1#%(AHpL z3A2J`4@KW+EA0-tquEL~Fl}Naj(;TH@@I%q6}S(hJd7~9_&^vw_PO8zMaqVa;R*5> zbFz4fIUPez&*=AQ{(q0SjDnu+Z{N1qBh_EPA6NMXE%EL_fBqT-{gW~lIE;)0BtBRPE@ujr@w{`Toji~Ul!jxY~-j6p~^lP+lwea4V3?XU9T z?B$D-U*i5c-Z~~Ldal1sTlk{4jy)n05A|ol3*jK)F6-h?{{9vA1@h(CXFLN3i=)2- zi#H8Hh&E4&0jUnN{hd5+vDCxx1#uq2UC1MUoXxL5?3e>=|+l z+9B~W4*H1s{--xBj>`-B0-OUEg-ec0>Brun*BBTBFvPDMdhN9+U$Q8Zg|@Z`&u(xI zoE!=ksruRex%8731)AhKh!gZK15uRvY2cqK0iSH0W#e6^4?AJR7po zs*OnZiX;EQVUeL0G+yu6eF3>8ewcQw76k;}3*FvqJ^{Y-2jm-QOjsFDg!^+O0rsX zE4&3fES;7nm#UPqf@vh%BEDO89(xO`$NueH{}w!H(Lxp(H17~cz5#EEI;KXWHrNzW zY{WF8J12OsWpo*u994<3Lf#;A+Kt0|Ad-#!oB95F(xSnr?t$O0b6vY8MBYDW<>w#R z20BpdgP_U>fnozwL${*5Ox!7IH5!Ae2tm}yy13#JJ3x=yw{!g)s7Z@n z9mEY&t*FXS-rhNK0G$fTv2>u*4l7G{w_n-N)r zpb3gStU+4f#-vHc&`wiO+R5SsM>LQKVN`m)KPD~oB7@cw2+AXFWCl5u&mwB_MP~6K zu{g~UG)VFQVN`0q|Fnf#WYFrra2&}Y4q^u!K?pE}V@MG-^$>bje=kgt1qRIt8a?n( zg2w`ZlMqPnOUSez&Gl!{qy;VUuH&~~KLL=01rOjJI&wkQJ49R2Vwo(do^&tqSuYHd zpRE!QBEEJX?vU!?mp@OZ#~J<}