Files
mail/patches/blitz/0002-reader-resources-and-image-performance.patch
T

720 lines
29 KiB
Diff

diff --git a/crates/blitz-dom/src/config.rs b/crates/blitz-dom/src/config.rs
index 4686b12..f6ad6a0 100644
--- a/crates/blitz-dom/src/config.rs
+++ b/crates/blitz-dom/src/config.rs
@@ -30,6 +30,8 @@ pub enum StyleThreading {
/// Options used when constructing a [`BaseDocument`](crate::BaseDocument)
pub struct DocumentConfig {
+ /// Optional bounded raster decoding for untrusted email content.
+ pub image_decode_limits: Option<crate::net::ImageDecodeLimits>,
/// The initial `Viewport`
pub viewport: Option<Viewport>,
/// The base url which relative URLs are resolved against
@@ -73,6 +75,7 @@ pub struct DocumentConfig {
impl Default for DocumentConfig {
fn default() -> Self {
Self {
+ image_decode_limits: None,
viewport: None,
base_url: None,
ua_stylesheets: vec![UserAgentStylesheet::default_sheet()],
diff --git a/crates/blitz-dom/src/document.rs b/crates/blitz-dom/src/document.rs
index 96d7ed0..a42379e 100644
--- a/crates/blitz-dom/src/document.rs
+++ b/crates/blitz-dom/src/document.rs
@@ -225,7 +225,70 @@ pub enum DocumentEvent {
},
}
+/// How many repainted nodes a document keeps before treating the whole
+/// document as damaged.
+const MAX_PAINT_UPDATES: usize = 4096;
+
+/// Damage from delivered resources. Paint-only updates preserve resolved layout.
+#[derive(Default, Debug)]
+pub struct ResourceUpdates {
+ pub changed: bool,
+ pub layout: bool,
+ pub paint_nodes: Vec<NodeId>,
+}
+
+/// Whether an image's layout cannot depend on its pixels, so a decoded image
+/// only needs a repaint. Its width and height must be fixed lengths, and its
+/// last layout must have used exactly those sizes: when a min/max constraint
+/// applies (such as a reader's `img { max-width: 100% }` in a narrow column),
+/// replaced sizing brings in the natural aspect ratio. Flex and grid items are
+/// excluded because their automatic minimum size depends on the image, as in
+/// Blink's `LayoutImage::NeedsLayoutOnNaturalSizeChange`.
+fn image_has_fixed_size(doc: &BaseDocument, node: &Node) -> bool {
+ use style::values::specified::box_::DisplayInside;
+ use taffy::CoreStyle as _;
+ if node.primary_styles().is_none() {
+ return false;
+ }
+ let in_flex_or_grid = node
+ .layout_parent
+ .get()
+ .and_then(|id| doc.get_node(id))
+ .and_then(|parent| parent.display_style())
+ .is_some_and(|display| {
+ matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid)
+ });
+ if in_flex_or_grid {
+ return false;
+ }
+ let style = node.layout_style();
+ let size = style.size();
+ let (Some(width), Some(height)) = (size.width.into_option(), size.height.into_option()) else {
+ return false;
+ };
+ if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
+ return false;
+ }
+ let border_box = style.box_sizing() == taffy::BoxSizing::BorderBox;
+ drop(style);
+ let layout = node.unrounded_layout();
+ let (width, height) = if border_box {
+ (width, height)
+ } else {
+ let (padding, border) = (layout.padding, layout.border);
+ (
+ width + padding.left + padding.right + border.left + border.right,
+ height + padding.top + padding.bottom + border.top + border.bottom,
+ )
+ };
+ (layout.size.width - width).abs() < 0.01 && (layout.size.height - height).abs() < 0.01
+}
+
pub struct BaseDocument {
+ pub(crate) image_decode_limits: Option<crate::net::ImageDecodeLimits>,
+ /// Damage from resource events handled since the embedder last drained
+ /// it, including events that `resolve` handled.
+ resource_updates: ResourceUpdates,
/// ID of the document
id: usize,
@@ -468,6 +531,8 @@ impl BaseDocument {
let (tx, rx) = channel();
let mut doc = Self {
+ image_decode_limits: config.image_decode_limits,
+ resource_updates: ResourceUpdates::default(),
id,
tx,
rx: Some(rx),
@@ -1256,17 +1321,75 @@ impl BaseDocument {
}
}
+ /// Handle pending resource events. Their damage accumulates until
+ /// `drain_pending_updates` takes it.
pub fn handle_messages(&mut self) {
+ let mut updates = std::mem::take(&mut self.resource_updates);
// Remove event Reciever from the Document so that we can process events
// without holding a borrow to the Document
let rx = self.rx.take().unwrap();
while let Ok(msg) = rx.try_recv() {
+ updates.changed = true;
+ match &msg {
+ DocumentEvent::ResourceLoad(res) => {
+ updates.layout |= self.pending_critical_resources.contains(&res.request_id);
+ match &res.result {
+ Ok(Resource::Image(..) | Resource::Raster(..)) => {
+ if let Some(waiting) = res
+ .resolved_url
+ .as_ref()
+ .and_then(|url| self.pending_images.get(url))
+ {
+ for &(id, kind) in waiting {
+ if matches!(kind, ImageType::Image)
+ && self
+ .get_node(id)
+ .is_some_and(|node| !image_has_fixed_size(self, node))
+ {
+ updates.layout = true;
+ }
+ updates.paint_nodes.push(id);
+ }
+ }
+ }
+ Ok(Resource::None) | Err(_) => {}
+ _ => updates.layout = true,
+ }
+ }
+ _ => updates.layout = true,
+ }
self.handle_message(msg);
}
// Put Reciever back
self.rx = Some(rx);
+ // An embedder that never drains must not grow the list without bound;
+ // past that, everything is damaged anyway.
+ if updates.paint_nodes.len() > MAX_PAINT_UPDATES {
+ updates.paint_nodes.sort_unstable();
+ updates.paint_nodes.dedup();
+ if updates.paint_nodes.len() > MAX_PAINT_UPDATES {
+ updates.paint_nodes.clear();
+ updates.layout = true;
+ }
+ }
+ self.resource_updates = updates;
+ }
+
+ /// Drain resource events, reporting whether layout may have changed.
+ pub fn drain_pending_messages(&mut self) -> bool {
+ self.drain_pending_updates().changed
+ }
+
+ /// Handle pending resource events and take the damage of every event
+ /// handled since the last call, including events `resolve` handled.
+ pub fn drain_pending_updates(&mut self) -> ResourceUpdates {
+ self.handle_messages();
+ let mut updates = std::mem::take(&mut self.resource_updates);
+ updates.paint_nodes.sort_unstable();
+ updates.paint_nodes.dedup();
+ updates
}
pub fn handle_message(&mut self, msg: DocumentEvent) {
@@ -1309,6 +1432,11 @@ impl BaseDocument {
};
match resource {
+ Resource::Raster(_, image) => {
+ if let Some(url) = res.resolved_url.as_ref() {
+ self.apply_loaded_image(url, ImageData::Raster(image));
+ }
+ }
Resource::Css(css) => {
let node_id = res.node_id.unwrap();
self.add_stylesheet_for_node(css, node_id);
@@ -1410,6 +1538,9 @@ impl BaseDocument {
// Apply to all waiting nodes
for (node_id, image_type) in waiting_nodes {
+ let fixed_size = self
+ .get_node(node_id)
+ .is_some_and(|node| image_has_fixed_size(self, node));
let Some(node) = self.get_node_mut(node_id) else {
continue;
};
@@ -1424,8 +1555,10 @@ impl BaseDocument {
element.special_data = SpecialElementData::Image(Box::new(image.clone()));
// Clear layout cache
- node.clear_layout_cache();
- node.insert_damage(ALL_DAMAGE);
+ if !fixed_size {
+ node.clear_layout_cache();
+ node.insert_damage(ALL_DAMAGE);
+ }
}
ImageType::Background(idx) | ImageType::Mask(idx) => {
let layer_image = node.element_data_mut().and_then(|el| {
@@ -2636,19 +2769,24 @@ impl BaseDocument {
}
let mut result = String::new();
+ let mut previous = None;
for (node_id, start, end) in &ranges {
let node = self.get_node(*node_id)?;
let element_data = node.element_data()?;
let inline_layout = element_data.inline_layout_data.as_ref()?;
- if *end > inline_layout.text.len() {
+ if inline_layout.text.get(*start..*end).is_none() {
continue;
}
-
- if !result.is_empty() {
- result.push(' ');
+ let text = self.visible_text(inline_layout, *start..*end);
+ if text.is_empty() {
+ continue;
+ }
+ if let Some(previous) = previous {
+ result.push_str(self.selection_separator(previous, *node_id));
}
- result.push_str(&inline_layout.text[*start..*end]);
+ result.push_str(&text);
+ previous = Some(*node_id);
}
if result.is_empty() {
@@ -2658,6 +2796,98 @@ impl BaseDocument {
}
}
+ /// The text of `range` in an inline formatting context, leaving out text
+ /// whose `visibility` hides it, as browsers do when copying.
+ fn visible_text(
+ &self,
+ inline_layout: &crate::node::TextLayout,
+ range: std::ops::Range<usize>,
+ ) -> String {
+ use style::computed_values::visibility::T as Visibility;
+ let mut hidden: Vec<std::ops::Range<usize>> = Vec::new();
+ let mut last: Option<(NodeId, bool)> = None;
+ for line in inline_layout.layout.lines() {
+ for run in line.runs() {
+ for cluster in run.clusters() {
+ let text_range = cluster.text_range();
+ if text_range.end <= range.start || range.end <= text_range.start {
+ continue;
+ }
+ // A run can mix styles, so each cluster is checked.
+ let id = cluster.style().brush.id;
+ let is_hidden = match last {
+ Some((last_id, is_hidden)) if last_id == id => is_hidden,
+ _ => {
+ let is_hidden = self
+ .get_node(id)
+ .and_then(|node| node.primary_styles())
+ .is_some_and(|styles| {
+ styles.get_inherited_box().visibility != Visibility::Visible
+ });
+ last = Some((id, is_hidden));
+ is_hidden
+ }
+ };
+ if is_hidden {
+ hidden
+ .push(text_range.start.max(range.start)..text_range.end.min(range.end));
+ }
+ }
+ }
+ }
+ hidden.sort_by_key(|hidden| hidden.start);
+
+ let text = &inline_layout.text;
+ let mut visible = String::new();
+ let mut cursor = range.start;
+ for hidden in hidden {
+ if hidden.start > cursor {
+ visible.push_str(text.get(cursor..hidden.start).unwrap_or_default());
+ }
+ cursor = cursor.max(hidden.end);
+ }
+ if cursor < range.end {
+ visible.push_str(text.get(cursor..range.end).unwrap_or_default());
+ }
+ visible
+ }
+
+ /// What separates the selected text of two consecutive inline formatting
+ /// contexts, as `innerText` and browsers' `Selection.toString()` do: a tab
+ /// between cells of one row, a blank line around a paragraph, and a line
+ /// break between other blocks.
+ fn selection_separator(&self, previous: NodeId, next: NodeId) -> &'static str {
+ use style::values::specified::box_::DisplayInside;
+ let ancestor = |id: NodeId, matches: &dyn Fn(&Node) -> bool| {
+ let mut current = Some(id);
+ while let Some(id) = current {
+ let node = self.get_node(id)?;
+ if matches(node) {
+ return Some(id);
+ }
+ current = node.parent;
+ }
+ None
+ };
+ let cell = |id| {
+ ancestor(id, &|node| {
+ node.display_style()
+ .is_some_and(|display| display.inside() == DisplayInside::TableCell)
+ })
+ };
+ let paragraph = |id| {
+ ancestor(id, &|node| {
+ node.data.is_element_with_tag_name(&local_name!("p"))
+ })
+ };
+ let parent = |id: NodeId| self.get_node(id).and_then(|node| node.parent);
+ match (cell(previous), cell(next)) {
+ (Some(a), Some(b)) if a != b && parent(a) == parent(b) => "\t",
+ _ if paragraph(previous) != paragraph(next) => "\n\n",
+ _ => "\n",
+ }
+ }
+
/// Get all selection ranges as Vec<(node_id, start_offset, end_offset)>.
/// Returns empty vec if no selection.
pub fn get_text_selection_ranges(&self) -> Vec<(NodeId, usize, usize)> {
diff --git a/crates/blitz-dom/src/iframe.rs b/crates/blitz-dom/src/iframe.rs
index c439fa8..0301fb6 100644
--- a/crates/blitz-dom/src/iframe.rs
+++ b/crates/blitz-dom/src/iframe.rs
@@ -55,6 +55,7 @@ impl BaseDocument {
abort_signal: AbortSignal,
) -> DocumentConfig {
DocumentConfig {
+ image_decode_limits: self.image_decode_limits,
viewport: None,
base_url,
ua_stylesheets: vec![UserAgentStylesheet::default_sheet()],
diff --git a/crates/blitz-dom/src/layout/damage.rs b/crates/blitz-dom/src/layout/damage.rs
index aaa5f5e..a48384e 100644
--- a/crates/blitz-dom/src/layout/damage.rs
+++ b/crates/blitz-dom/src/layout/damage.rs
@@ -611,7 +611,8 @@ impl BaseDocument {
doc_id,
None, // Don't pass node_id, we'll handle via pending_images
self.shell_provider.clone(),
- ImageHandler::new(kind.image_type(idx)),
+ ImageHandler::new(kind.image_type(idx))
+ .with_limits(self.image_decode_limits),
),
);
diff --git a/crates/blitz-dom/src/mutator.rs b/crates/blitz-dom/src/mutator.rs
index feac670..7427db4 100644
--- a/crates/blitz-dom/src/mutator.rs
+++ b/crates/blitz-dom/src/mutator.rs
@@ -1194,7 +1194,8 @@ impl<'doc> DocumentMutator<'doc> {
self.doc.id(),
None, // Don't pass node_id, we'll handle it via pending_images
self.doc.shell_provider.clone(),
- ImageHandler::new(ImageType::Image),
+ ImageHandler::new(ImageType::Image)
+ .with_limits(self.doc.image_decode_limits),
),
);
}
diff --git a/crates/blitz-dom/src/net.rs b/crates/blitz-dom/src/net.rs
index 786b5bd..02b5379 100644
--- a/crates/blitz-dom/src/net.rs
+++ b/crates/blitz-dom/src/net.rs
@@ -24,6 +24,9 @@ use blitz_traits::shell::ShellProvider;
use url::Url;
+mod image_decode;
+pub use image_decode::ImageDecodeLimits;
+
use crate::{document::DocumentEvent, util::ImageType};
pub(crate) fn stamped_request(url: Url, signal: Option<&AbortSignal>) -> Request {
@@ -56,6 +59,7 @@ pub struct FontFaceOverrides {
#[derive(Clone, Debug)]
pub enum Resource {
+ Raster(ImageType, crate::node::RasterImageData),
Image(ImageType, u32, u32, Arc<Vec<u8>>),
#[cfg(feature = "svg")]
Svg(ImageType, crate::node::SvgImageData),
@@ -553,10 +557,15 @@ impl NetHandler for ResourceHandler<DocumentSrcHandler> {
pub struct ImageHandler {
kind: ImageType,
+ limits: Option<ImageDecodeLimits>,
}
impl ImageHandler {
pub fn new(kind: ImageType) -> Self {
- Self { kind }
+ Self { kind, limits: None }
+ }
+ pub fn with_limits(mut self, limits: Option<ImageDecodeLimits>) -> Self {
+ self.limits = limits;
+ self
}
}
@@ -569,23 +578,36 @@ impl NetHandler for ResourceHandler<ImageHandler> {
impl ImageHandler {
fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
- let image_err = match image::ImageReader::new(Cursor::new(&bytes))
- .with_guessed_format()
- .expect("IO errors impossible with Cursor")
- .decode()
- {
- Ok(image) => {
- let width = image.width();
- let height = image.height();
- let raw_rgba8_data = image.into_rgba8().into_raw();
- return Ok(Resource::Image(
- self.kind,
- width,
- height,
- Arc::new(raw_rgba8_data),
- ));
+ let image_err = if let Some(limits) = self.limits {
+ // The limits bound raster decoding. Bytes in no raster format may
+ // still be an SVG, which is parsed below as without limits.
+ match image::guess_format(&bytes) {
+ Ok(_) => {
+ return limits
+ .decode(&bytes)
+ .map(|image| Resource::Raster(self.kind, image));
+ }
+ Err(e) => e.to_string(),
+ }
+ } else {
+ match image::ImageReader::new(Cursor::new(&bytes))
+ .with_guessed_format()
+ .expect("IO errors impossible with Cursor")
+ .decode()
+ {
+ Ok(image) => {
+ let width = image.width();
+ let height = image.height();
+ let raw_rgba8_data = image.into_rgba8().into_raw();
+ return Ok(Resource::Image(
+ self.kind,
+ width,
+ height,
+ Arc::new(raw_rgba8_data),
+ ));
+ }
+ Err(e) => e.to_string(),
}
- Err(e) => e.to_string(),
};
#[cfg(feature = "svg")]
diff --git a/crates/blitz-dom/src/net/image_decode.rs b/crates/blitz-dom/src/net/image_decode.rs
new file mode 100644
index 0000000..0f90e35
--- /dev/null
+++ b/crates/blitz-dom/src/net/image_decode.rs
@@ -0,0 +1,236 @@
+use crate::node::RasterImageData;
+use image::metadata::Orientation;
+use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
+use std::{io::Cursor, sync::Arc};
+
+#[derive(Clone, Copy, Debug)]
+pub struct ImageDecodeLimits {
+ pub max_source_dimension: u32,
+ pub max_source_pixels: u64,
+ /// JPEGs can be admitted at a larger source size because the decoder uses
+ /// native IDCT scaling before allocating the retained pixel buffer.
+ /// Progressive and lossless JPEGs keep full-resolution coefficients while
+ /// decoding, so they are held to `max_source_pixels`.
+ pub max_jpeg_source_dimension: u32,
+ pub max_jpeg_source_pixels: u64,
+ /// The largest pixel buffer a JPEG decode may allocate.
+ pub max_alloc: u64,
+ /// The longest side a decoded image keeps.
+ pub target_dimension: u32,
+ /// The most pixels a decoded image keeps. Bounding the area rather than
+ /// only the longer side keeps tall and wide images sharp for the same
+ /// memory.
+ pub target_pixels: u64,
+}
+
+impl ImageDecodeLimits {
+ /// Bound decoding and retain intrinsic dimensions independently of pixels.
+ /// JPEG uses native IDCT scaling; other formats are resized after their
+ /// bounded decode. The first frame is used for animated formats.
+ ///
+ /// EXIF orientation is applied, as `image-orientation: from-image` is the
+ /// CSS default, so the intrinsic size is that of the upright image.
+ pub fn decode(self, bytes: &[u8]) -> Result<RasterImageData, String> {
+ let reader = ImageReader::new(Cursor::new(bytes))
+ .with_guessed_format()
+ .map_err(|e| e.to_string())?;
+ let is_jpeg = reader.format() == Some(ImageFormat::Jpeg);
+ let mut decoder = reader.into_decoder().map_err(|e| e.to_string())?;
+ let (width, height) = decoder.dimensions();
+ let (max_source_dimension, max_source_pixels) = if is_jpeg {
+ (self.max_jpeg_source_dimension, self.max_jpeg_source_pixels)
+ } else {
+ (self.max_source_dimension, self.max_source_pixels)
+ };
+ if width == 0
+ || height == 0
+ || width > max_source_dimension
+ || height > max_source_dimension
+ || u64::from(width) * u64::from(height) > max_source_pixels
+ || self.target_dimension == 0
+ || self.target_pixels == 0
+ {
+ return Err("Image dimensions exceed the document decoder limits".into());
+ }
+ // Malformed metadata must not lose an otherwise valid image.
+ let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
+
+ let target = self.target_size(width, height);
+ let scaled_jpeg = if is_jpeg && target != (width, height) {
+ self.decode_scaled_jpeg(bytes, width, height, target)?
+ } else {
+ None
+ };
+ let decoded = match scaled_jpeg {
+ Some(image) => image,
+ None => {
+ let mut limits = image::Limits::default();
+ limits.max_image_width = Some(max_source_dimension);
+ limits.max_image_height = Some(max_source_dimension);
+ limits.max_alloc = Some(self.max_alloc);
+ decoder.set_limits(limits).map_err(|e| e.to_string())?;
+ // The general decoder allocates the whole source at once, and
+ // `set_limits` does not check that allocation. Other formats are
+ // already bounded by `max_source_pixels`; a JPEG admitted under
+ // the larger JPEG limits must fit `max_alloc` at full size.
+ if is_jpeg && decoder.total_bytes() > self.max_alloc {
+ return Err("JPEG exceeds the document decoder's allocation limit".into());
+ }
+ DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?
+ }
+ };
+ let mut decoded = downscale(decoded, target);
+ decoded.apply_orientation(orientation);
+ let (width, height) = match orientation {
+ Orientation::Rotate90
+ | Orientation::Rotate270
+ | Orientation::Rotate90FlipH
+ | Orientation::Rotate270FlipH => (height, width),
+ _ => (width, height),
+ };
+
+ let pixel_width = decoded.width();
+ let pixel_height = decoded.height();
+ let mut image =
+ RasterImageData::new(width, height, Arc::new(decoded.into_rgba8().into_raw()));
+ image.pixel_width = pixel_width;
+ image.pixel_height = pixel_height;
+ Ok(image)
+ }
+
+ /// The largest size a `width` x `height` image is decoded to: within both
+ /// the longest side and the pixel budget, keeping its aspect ratio.
+ pub fn target_size(self, width: u32, height: u32) -> (u32, u32) {
+ let (w, h) = (f64::from(width), f64::from(height));
+ let scale = (f64::from(self.target_dimension) / w.max(h))
+ .min((self.target_pixels as f64 / (w * h)).sqrt())
+ .min(1.0);
+ if scale >= 1.0 {
+ return (width, height);
+ }
+ let fit = |side: f64| ((side * scale).floor() as u32).max(1);
+ (fit(w), fit(h))
+ }
+
+ /// Decode a large JPEG at a reduced size using the decoder's IDCT scaling,
+ /// at the smallest reduction that keeps the target resolution and fits
+ /// `max_alloc`. `Ok(None)` defers to the general decoder for streams this
+ /// decoder rejects.
+ fn decode_scaled_jpeg(
+ self,
+ bytes: &[u8],
+ width: u32,
+ height: u32,
+ (target_width, target_height): (u32, u32),
+ ) -> Result<Option<DynamicImage>, String> {
+ let mut decoder = jpeg_decoder::Decoder::new(Cursor::new(bytes));
+ decoder.set_max_decoding_buffer_size(self.max_alloc.min(usize::MAX as u64) as usize);
+ if decoder.read_info().is_err() {
+ return Ok(None);
+ }
+ let Some(info) = decoder.info() else {
+ return Ok(None);
+ };
+ // Progressive and lossless scans keep every full-resolution coefficient
+ // until the last scan, whatever the output scale. The general decoder
+ // does the same, so these are refused rather than deferred.
+ if info.coding_process != jpeg_decoder::CodingProcess::DctSequential
+ && u64::from(width) * u64::from(height) > self.max_source_pixels
+ {
+ return Err("Progressive JPEG exceeds the document decoder limits".into());
+ }
+ let channels: u64 = match info.pixel_format {
+ jpeg_decoder::PixelFormat::RGB24 => 3,
+ jpeg_decoder::PixelFormat::L8 => 1,
+ jpeg_decoder::PixelFormat::CMYK32 => 4,
+ _ => return Ok(None),
+ };
+ let mut divisor = [8, 4, 2]
+ .into_iter()
+ .find(|&divisor| {
+ width.div_ceil(divisor) >= target_width && height.div_ceil(divisor) >= target_height
+ })
+ .unwrap_or(1);
+ let scaled_bytes = |divisor: u32| {
+ u64::from(width.div_ceil(divisor)) * u64::from(height.div_ceil(divisor)) * channels
+ };
+ while divisor < 8 && scaled_bytes(divisor) > self.max_alloc {
+ divisor *= 2;
+ }
+ let Ok((w, h)) = decoder.scale(
+ width.div_ceil(divisor) as u16,
+ height.div_ceil(divisor) as u16,
+ ) else {
+ return Ok(None);
+ };
+ let Ok(pixels) = decoder.decode() else {
+ return Ok(None);
+ };
+ let (w, h) = (u32::from(w), u32::from(h));
+ Ok(match info.pixel_format {
+ jpeg_decoder::PixelFormat::RGB24 => {
+ image::RgbImage::from_raw(w, h, pixels).map(DynamicImage::ImageRgb8)
+ }
+ jpeg_decoder::PixelFormat::CMYK32 => {
+ image::RgbImage::from_raw(w, h, cmyk_to_rgb(&pixels)).map(DynamicImage::ImageRgb8)
+ }
+ _ => image::GrayImage::from_raw(w, h, pixels).map(DynamicImage::ImageLuma8),
+ })
+ }
+}
+
+/// Convert the decoder's CMYK output to RGB, as `image` did while it decoded
+/// JPEGs with jpeg-decoder.
+fn cmyk_to_rgb(cmyk: &[u8]) -> Vec<u8> {
+ cmyk.as_chunks::<4>()
+ .0
+ .iter()
+ .flat_map(|pixel| {
+ let k = 255 - u16::from(pixel[3]);
+ [0, 1, 2].map(|channel| ((255 - u16::from(pixel[channel])) * k / 255) as u8)
+ })
+ .collect()
+}
+
+/// Reduce an image to `target`, the size `target_size` gives its source.
+/// Colour is averaged premultiplied, so fully transparent pixels do not
+/// darken the edges of translucent images.
+fn downscale(image: DynamicImage, (target_width, target_height): (u32, u32)) -> DynamicImage {
+ if image.width() <= target_width && image.height() <= target_height {
+ return image;
+ }
+ if !image.color().has_alpha() {
+ return image.thumbnail_exact(target_width, target_height);
+ }
+ let mut rgba = image.into_rgba8();
+ for pixel in rgba.pixels_mut() {
+ let alpha = u16::from(pixel[3]);
+ for channel in &mut pixel.0[..3] {
+ *channel = ((u16::from(*channel) * alpha + 127) / 255) as u8;
+ }
+ }
+ let mut reduced = DynamicImage::ImageRgba8(rgba)
+ .thumbnail_exact(target_width, target_height)
+ .into_rgba8();
+ for pixel in reduced.pixels_mut() {
+ let alpha = u16::from(pixel[3]);
+ for channel in &mut pixel.0[..3] {
+ if let Some(value) = (u16::from(*channel) * 255 + alpha / 2).checked_div(alpha) {
+ *channel = value.min(255) as u8;
+ }
+ }
+ }
+ DynamicImage::ImageRgba8(reduced)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::cmyk_to_rgb;
+
+ #[test]
+ fn cmyk_ink_converts_to_rgb() {
+ // White paper, full cyan, and full black.
+ let cmyk = [0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255];
+ assert_eq!(cmyk_to_rgb(&cmyk), [255, 255, 255, 0, 255, 255, 0, 0, 0]);
+ }
+}