diff --git a/Cargo.lock b/Cargo.lock index 2c0bfe138..bea66f1e5 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 b4f704f80..ea4678839 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 77e1d0838..1ab90c3c8 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::Readable; use typst_library::text::families; use typst_library::visualize::{ - Curve, Image, ImageElem, ImageFit, ImageFormat, RasterFormat, VectorFormat, + Curve, Image, ImageElem, ImageFit, ImageFormat, ImageSource, RasterFormat, + VectorFormat, }; /// Layout the image. @@ -26,31 +27,38 @@ pub fn layout_image( // Take the format that was explicitly defined, or parse the extension, // or try to detect the format. - let data = elem.data(); - let format = match elem.format(styles) { - Smart::Custom(v) => v, - Smart::Auto => determine_format(elem.path().as_str(), data).at(span)?, + let source = elem.source(); + let format = match (elem.format(styles), source) { + (Smart::Custom(v), _) => v, + (Smart::Auto, ImageSource::Readable(data)) => { + determine_format(elem.path().as_str(), data).at(span)? + } + (Smart::Auto, ImageSource::Pixmap(_)) => { + bail!(span, "pixmaps require an explicit image format to be given"); + } }; // Warn the user if the image contains a foreign object. Not perfect // because the svg could also be encoded, but that's an edge case. - if format == ImageFormat::Vector(VectorFormat::Svg) { - let has_foreign_object = - data.as_str().is_some_and(|s| s.contains(" Option<()> { let image = - Image::new(raster_image.data.into(), RasterFormat::Png.into(), None).ok()?; + Image::new(Bytes::from(raster_image.data).into(), RasterFormat::Png.into(), None) + .ok()?; // Apple Color emoji doesn't provide offset information (or at least // not in a way ttf-parser understands), so we artificially shift their @@ -175,7 +180,8 @@ fn draw_colr_glyph( let data = svg.end_document().into_bytes(); - let image = Image::new(data.into(), VectorFormat::Svg.into(), None).ok()?; + let image = + Image::new(Bytes::from(data).into(), VectorFormat::Svg.into(), None).ok()?; let y_shift = Abs::pt(upem.to_pt() - y_max); let position = Point::new(Abs::pt(x_min), y_shift); @@ -250,9 +256,8 @@ fn draw_svg_glyph( ty = -top, ); - let image = - Image::new(wrapper_svg.into_bytes().into(), VectorFormat::Svg.into(), None) - .ok()?; + let source = ImageSource::Readable(Readable::Str(wrapper_svg.into())); + let image = Image::new(source, VectorFormat::Svg.into(), None).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 452bb65c1..8b1fec3ce 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -1,5 +1,6 @@ //! Image handling. +mod pixmap; mod raster; mod svg; @@ -11,14 +12,15 @@ use std::sync::Arc; use comemo::Tracked; use ecow::EcoString; +use pixmap::{Pixmap, PixmapFormat, PixmapSource}; use typst_syntax::{Span, Spanned}; use typst_utils::LazyHash; -use crate::diag::{At, SourceResult, StrResult}; +use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - cast, elem, func, scope, Bytes, Cast, Content, NativeElement, Packed, Show, Smart, - StyleChain, + cast, elem, func, scope, Bytes, Cast, Content, Dict, NativeElement, Packed, Show, + Smart, StyleChain, }; use crate::layout::{BlockElem, Length, Rel, Sizing}; use crate::loading::Readable; @@ -60,11 +62,11 @@ pub struct ImageElem { #[borrowed] pub path: EcoString, - /// The raw file data. + /// The data required to decode the image. #[internal] #[required] - #[parse(Readable::Bytes(data))] - pub data: Readable, + #[parse(data.into())] + pub source: ImageSource, /// The image's format. Detected automatically by default. /// @@ -103,6 +105,7 @@ pub struct ImageElem { } #[scope] +#[allow(clippy::too_many_arguments)] impl ImageElem { /// Decode a raster or vector graphic from bytes or a string. /// @@ -121,7 +124,7 @@ impl ImageElem { /// The call span of this function. span: Span, /// The data to decode as an image. Can be a string for SVGs. - data: Readable, + source: ImageSource, /// The image's format. Detected automatically by default. #[named] format: Option>, @@ -138,7 +141,7 @@ impl ImageElem { #[named] fit: Option, ) -> StrResult { - let mut elem = ImageElem::new(EcoString::new(), data); + let mut elem = ImageElem::new(EcoString::new(), source); if let Some(format) = format { elem.push_format(format); } @@ -213,6 +216,8 @@ pub enum ImageKind { Raster(RasterImage), /// An SVG image. Svg(SvgImage), + /// An image constructed from a pixmap. + Pixmap(Pixmap), } impl Image { @@ -223,20 +228,32 @@ 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. + /// Create an image from a source and a format. #[comemo::memoize] #[typst_macros::time(name = "load image")] pub fn new( - data: Bytes, + source: ImageSource, format: ImageFormat, alt: Option, ) -> StrResult { let kind = match format { ImageFormat::Raster(format) => { - ImageKind::Raster(RasterImage::new(data, format)?) + let ImageSource::Readable(readable) = source else { + bail!("expected readable source for the given format (str or bytes)"); + }; + ImageKind::Raster(RasterImage::new(readable.into(), format)?) } ImageFormat::Vector(VectorFormat::Svg) => { - ImageKind::Svg(SvgImage::new(data)?) + let ImageSource::Readable(readable) = source else { + bail!("expected readable source for the given format (str or bytes)"); + }; + ImageKind::Svg(SvgImage::new(readable.into())?) + } + ImageFormat::Pixmap(format) => { + let ImageSource::Pixmap(source) = source else { + bail!("source must be a pixmap"); + }; + ImageKind::Pixmap(Pixmap::new(source, format)?) } }; @@ -247,7 +264,7 @@ impl Image { #[comemo::memoize] #[typst_macros::time(name = "load image")] pub fn with_fonts( - data: Bytes, + source: ImageSource, format: ImageFormat, alt: Option, world: Tracked, @@ -256,29 +273,39 @@ impl Image { ) -> StrResult { let kind = match format { ImageFormat::Raster(format) => { - ImageKind::Raster(RasterImage::new(data, format)?) + let ImageSource::Readable(readable) = source else { + bail!("expected readable source for the given format (str or bytes)"); + }; + ImageKind::Raster(RasterImage::new(readable.into(), format)?) } ImageFormat::Vector(VectorFormat::Svg) => { - ImageKind::Svg(SvgImage::with_fonts(data, world, flatten_text, families)?) + let ImageSource::Readable(readable) = source else { + bail!("expected readable source for the given format (str or bytes)"); + }; + ImageKind::Svg(SvgImage::with_fonts( + readable.into(), + world, + flatten_text, + families, + )?) + } + ImageFormat::Pixmap(format) => { + let ImageSource::Pixmap(source) = source else { + bail!("source must be pixmap"); + }; + ImageKind::Pixmap(Pixmap::new(source, format)?) } }; 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(), - } - } - /// The format of the image. pub fn format(&self) -> ImageFormat { match &self.0.kind { ImageKind::Raster(raster) => raster.format().into(), ImageKind::Svg(_) => VectorFormat::Svg.into(), + ImageKind::Pixmap(pixmap) => pixmap.format().into(), } } @@ -287,6 +314,7 @@ 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, } } @@ -295,6 +323,7 @@ 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, } } @@ -303,6 +332,7 @@ impl Image { match &self.0.kind { ImageKind::Raster(raster) => raster.dpi(), ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI), + ImageKind::Pixmap(_) => None, } } @@ -328,6 +358,34 @@ impl Debug for Image { } } +/// Information required to decode an image. +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum ImageSource { + Readable(Readable), + Pixmap(Arc), +} + +impl From for ImageSource { + fn from(bytes: Bytes) -> Self { + ImageSource::Readable(Readable::Bytes(bytes)) + } +} + +cast! { + ImageSource, + data: Readable => ImageSource::Readable(data), + mut dict: Dict => { + let source = ImageSource::Pixmap(Arc::new(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(|value| value.cast()).transpose()?, + })); + dict.finish(&["data", "pixel-width", "pixel-height", "icc-profile"])?; + source + }, +} + /// A raster or vector image format. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum ImageFormat { @@ -335,6 +393,8 @@ pub enum ImageFormat { Raster(RasterFormat), /// A vector graphics format. Vector(VectorFormat), + /// A format made up of flat pixels without metadata or compression. + Pixmap(PixmapFormat), } /// A vector graphics format. @@ -356,12 +416,20 @@ 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::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), } diff --git a/crates/typst-library/src/visualize/image/pixmap.rs b/crates/typst-library/src/visualize/image/pixmap.rs new file mode 100644 index 000000000..cc0358f87 --- /dev/null +++ b/crates/typst-library/src/visualize/image/pixmap.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use image::{DynamicImage, ImageBuffer, Pixel}; + +use crate::diag::{bail, StrResult}; +use crate::foundations::{Bytes, Cast}; + +#[derive(Debug, PartialEq, Hash)] +pub struct PixmapSource { + pub data: Bytes, + pub pixel_width: u32, + pub pixel_height: u32, + pub icc_profile: Option, +} + +/// A raster image based on a flat pixmap. +#[derive(Clone, Hash)] +pub struct Pixmap(Arc); + +/// The internal representation. +#[derive(Hash)] +struct Repr { + source: Arc, + format: PixmapFormat, +} + +impl Pixmap { + /// Build a new [`Pixmap`] from a flat, uncompressed byte sequence. + #[comemo::memoize] + pub fn new(source: Arc, 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!("provided pixel dimensions are too large"); + }; + if expected_size as usize != source.data.len() { + bail!("provided pixel dimensions and pixmap 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) -> &[u8] { + self.0.source.data.as_slice() + } + + /// Transform the image data into an [`DynamicImage`]. + #[comemo::memoize] + pub fn to_image(&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 { + /// The red, green, and blue channels are each eight bit integers. + /// There is no alpha channel. + Rgb8, + /// The red, green, blue, and alpha channels are each eight bit integers. + Rgba8, + /// A single eight bit channel representing brightness. + Luma8, + /// One byte of brightness, another for alpha. + Lumaa8, +} diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 829826c75..1686fed40 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -21,7 +21,7 @@ struct Repr { data: Bytes, format: RasterFormat, dynamic: image::DynamicImage, - icc: Option>, + icc_profile: Option>, dpi: Option, } @@ -40,7 +40,7 @@ impl RasterImage { } let cursor = io::Cursor::new(&data); - let (mut dynamic, icc) = match format { + 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)), @@ -59,7 +59,7 @@ impl RasterImage { // Extract pixel density. let dpi = determine_dpi(&data, exif.as_ref()); - Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) + Ok(Self(Arc::new(Repr { data, format, dynamic, icc_profile, dpi }))) } /// The raw image data. @@ -93,8 +93,8 @@ impl RasterImage { } /// Access the ICC profile, if any. - pub fn icc(&self) -> Option<&[u8]> { - self.0.icc.as_deref() + pub fn icc_profile(&self) -> Option<&[u8]> { + self.0.icc_profile.as_deref() } } diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index bff7bfefa..a912d68f4 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,9 +5,7 @@ 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, RasterImage, SvgImage, -}; +use typst_library::visualize::{ColorSpace, Image, ImageKind, RasterFormat, SvgImage}; use typst_utils::Deferred; use crate::{color, deflate, PdfChunk, WithGlobalRefs}; @@ -32,10 +30,11 @@ pub fn write_images( EncodedImage::Raster { data, filter, - has_color, + color_space, + bits_per_component, width, height, - icc, + icc_profile, alpha, } => { let image_ref = chunk.alloc(); @@ -45,23 +44,17 @@ 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)); let mut icc_ref = None; let space = image.color_space(); - if icc.is_some() { + if icc_profile.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 +72,24 @@ 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)); } 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(icc_profile), Some(icc_ref)) = (icc_profile, icc_ref) { + let mut stream = chunk.icc_profile(icc_ref, icc_profile); 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!(), } } } @@ -121,82 +118,97 @@ pub fn deferred_image( pdfa: bool, ) -> (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) - } + ImageKind::Raster(raster) if raster.icc_profile().is_none() => { + Some(to_color_space(raster.dynamic().color())) + } + ImageKind::Pixmap(pixmap) if pixmap.icc_profile().is_none() => { + Some(to_color_space(pixmap.to_image().color())) } _ => None, }; 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, - }) + let format = if raster.format() == RasterFormat::Jpg { + EncodeFormat::DctDecode + } else { + EncodeFormat::Flate + }; + Ok(encode_raster_image(&raster.dynamic(), raster.icc_profile(), format)) } 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_image(), + pixmap.icc_profile(), + EncodeFormat::Flate, + )), }); (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) { - let dynamic = image.dynamic(); - let channel_count = dynamic.color().channel_count(); - let has_color = channel_count > 2; +fn encode_raster_image( + image: &DynamicImage, + icc_profile: Option<&[u8]>, + format: EncodeFormat, +) -> EncodedImage { + let color_space = to_color_space(image.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()), - }; - (data, Filter::FlateDecode, has_color) + let (filter, data, bits_per_component) = match format { + EncodeFormat::DctDecode => { + let mut data = Cursor::new(vec![]); + image.write_to(&mut data, image::ImageFormat::Jpeg).unwrap(); + (Filter::DctDecode, data.into_inner(), 8) + } + EncodeFormat::Flate => { + // TODO: Encode flate streams with PNG-predictor? + let (data, bits_per_component) = match (image, color_space) { + (DynamicImage::ImageRgb8(rgb), _) => (deflate(rgb.as_raw()), 8), + // Grayscale image + (DynamicImage::ImageLuma8(luma), _) => (deflate(luma.as_raw()), 8), + (_, ColorSpace::D65Gray) => (deflate(image.to_luma8().as_raw()), 8), + // Anything else + _ => (deflate(image.to_rgb8().as_raw()), 8), + }; + (Filter::FlateDecode, data, bits_per_component) + } + }; + + let compressed_icc = icc_profile.map(deflate); + let alpha = image.color().has_alpha().then(|| encode_alpha(image)); + + EncodedImage::Raster { + data, + filter, + color_space, + bits_per_component, + width: image.width(), + height: image.height(), + icc_profile: compressed_icc, + alpha, + } +} + +/// 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!(), } } /// 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,14 +236,16 @@ 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>, + icc_profile: Option>, /// The alpha channel of the image, pre-deflated, if any. alpha: Option<(Vec, Filter)>, }, @@ -240,3 +254,9 @@ pub enum EncodedImage { /// The chunk is the SVG converted to PDF objects. Svg(Chunk, Ref), } + +/// How the raster image should be encoded. +enum EncodeFormat { + DctDecode, + Flate, +} diff --git a/crates/typst-render/src/image.rs b/crates/typst-render/src/image.rs index 27b039113..5a487fbc0 100644 --- a/crates/typst-render/src/image.rs +++ b/crates/typst-render/src/image.rs @@ -34,7 +34,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 +57,35 @@ 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> { 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 Rgba([r, g, b, a]) = src; - *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply(); - } - } + ImageKind::Raster(raster) => scale_image(raster.dynamic(), w, h), + ImageKind::Pixmap(raster) => scale_image(&raster.to_image(), w, h), // Safety: We do not keep any references to tree nodes beyond the scope // of `with`. ImageKind::Svg(svg) => { + let mut pixmap = sk::Pixmap::new(w, h)?; 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 pixmap.as_mut()); + Some(Arc::new(pixmap)) } } +} + +/// 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> { + let mut pixmap = sk::Pixmap::new(w, h)?; + let downscale = w < image.width(); + let filter = if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom }; + let buf = image.resize(w, h, filter); + for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) { + let Rgba([r, g, b, a]) = src; + *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply(); + } Some(Arc::new(pixmap)) } 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..953630766 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -1,7 +1,13 @@ +use std::io::Cursor; + use base64::Engine; use ecow::{eco_format, EcoString}; +use image::error::UnsupportedError; +use image::{codecs::png::PngEncoder, ImageEncoder}; use typst_library::layout::{Abs, Axes}; -use typst_library::visualize::{Image, ImageFormat, RasterFormat, VectorFormat}; +use typst_library::visualize::{ + Image, ImageFormat, ImageKind, RasterFormat, VectorFormat, +}; use crate::SVGRenderer; @@ -31,10 +37,27 @@ pub fn convert_image_to_base64_url(image: &Image) -> EcoString { ImageFormat::Vector(f) => match f { VectorFormat::Svg => "svg+xml", }, + ImageFormat::Pixmap(_) => "png", + }; + let data_owned; + let data = match image.kind() { + ImageKind::Raster(raster) => raster.data(), + ImageKind::Svg(svg) => svg.data(), + ImageKind::Pixmap(pixmap) => { + let mut data = Cursor::new(vec![]); + let mut encoder = PngEncoder::new(&mut data); + if let Some(icc_profile) = pixmap.icc_profile() { + let _: Result<(), UnsupportedError> = + encoder.set_icc_profile(icc_profile.to_vec()); + } + pixmap.to_image().write_with_encoder(encoder).unwrap(); + data_owned = data.into_inner(); + &*data_owned + } }; 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 80de32089..a1bd286a0 100644 --- a/crates/typst-svg/src/text.rs +++ b/crates/typst-svg/src/text.rs @@ -3,6 +3,7 @@ use std::io::Read; use base64::Engine; use ecow::EcoString; 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}; @@ -243,7 +244,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(raster.data.into(), RasterFormat::Png.into(), None).ok()?; + let image = + Image::new(Bytes::from(raster.data).into(), RasterFormat::Png.into(), None) + .ok()?; Some((image, raster.x as f64, raster.y as f64)) } 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..69db78177 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/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 846650c68..8883627b0 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -65,6 +65,58 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B caption: [Bilingual text] ) +--- image-pixmap-rgb8 --- +#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, 0x80, 0x80, 0x00, 0x80, 0x00, 0x80, + )), + pixel-width: 3, + pixel-height: 3, + ), + format: "rgb8", + width: 1cm, +) + +--- image-pixmap-rgba8 --- +#image.decode( + ( + 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, + ), + format: "rgba8", + width: 1cm, +) + +--- image-pixmap-luma8 --- +#image.decode( + ( + data: bytes(range(16).map(x => x * 16)), + pixel-width: 4, + pixel-height: 4, + ), + format: "luma8", + width: 1cm, +) + +--- image-pixmap-lumaa8 --- +#image.decode( + ( + data: bytes(range(16).map(x => (0x80, x * 16)).flatten()), + pixel-width: 4, + pixel-height: 4, + ), + format: "lumaa8", + width: 1cm, +) + --- 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 +155,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.decode( + ( + data: bytes(()), + pixel-width: 0, + pixel-height: 0, + ), + format: "rgb8", +) + +--- image-pixmap-invalid-size --- +// Error: 1:2-8:2 provided pixel dimensions and pixmap data do not match +#image.decode( + ( + data: bytes((0x00, 0x00, 0x00)), + pixel-width: 16, + pixel-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.decode( + ( + data: bytes((0x00, 0x00, 0x00)), + pixel-width: 1, + pixel-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.decode( + ( + data: bytes((0x00, 0x00, 0x00)), + pixel-width: 1, + pixel-height: 1, + ), + format: "png", +) + +--- image-png-but-pixmap-format --- +// Error: 1:2-4:2 source must be pixmap +#image.decode( + read("/assets/images/tiger.jpg", encoding: none), + format: "rgba8", +) + --- issue-870-image-rotation --- // Ensure that EXIF rotation is applied. // https://github.com/image-rs/image/issues/1045