diff --git a/Cargo.lock b/Cargo.lock index 9760f61ff..09e504a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1574,6 +1574,7 @@ dependencies = [ name = "designcraft-render" version = "0.20.1" dependencies = [ + "bytemuck", "craft-pdf", "designcraft-color", "designcraft-compose", diff --git a/Cargo.toml b/Cargo.toml index dcf2c7235..d90986f91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ skrifa = "0.47" harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } +# Decoded image buffers become pixmap pixels without a copy; the version vello_common uses. +bytemuck = { version = "1.25", features = ["extern_crate_alloc"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } # CMYK TIFF samples for PDF export (`image` decodes them to RGB); the version `image` uses. tiff = "0.11" diff --git a/ROADMAP.md b/ROADMAP.md index 402c81438..f7f8aff00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. No readiness percentage change. - **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 8cd446af1..ac5d8a0f1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -16,6 +16,7 @@ designcraft-fonts = { workspace = true } designcraft-compose = { workspace = true } vello_cpu = { workspace = true } vello_common = { workspace = true } +bytemuck = { workspace = true } image = { workspace = true } craft-pdf = { workspace = true, features = ["raster"] } designcraft-images = { workspace = true } diff --git a/crates/render/src/decode.rs b/crates/render/src/decode.rs index 135dbf108..d720362c7 100644 --- a/crates/render/src/decode.rs +++ b/crates/render/src/decode.rs @@ -16,23 +16,39 @@ pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option { } if designcraft_images::is_svg(bytes) { let (px, w, h) = designcraft_images::render_svg(bytes, 3000)?; - let data = px.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect(); - return Some(Pixmap::from_parts(data, w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16)); + return Some(Pixmap::from_parts(premul_pixels(px), w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16)); } let img = designcraft_images::decode_rgba(bytes)?; let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; } - let data: Vec = img - .pixels() - .map(|p| { - let a = p[3] as u16; - let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; - vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } - }) - .collect(); - Some(Pixmap::from_parts(data, w as u16, h as u16)) + let mut px = img.into_raw(); + px.truncate(w as usize * h as usize * 4); + Some(Pixmap::from_parts(premultiply(px), w as u16, h as u16)) +} + +/// Premultiply straight-alpha RGBA bytes in place and return them as pixmap pixels. Opaque pixels +/// are left as they are. +pub(crate) fn premultiply(mut px: Vec) -> Vec { + for p in px.as_chunks_mut::<4>().0 { + let a = p[3]; + if a != 255 { + let m = |c: u8| ((c as u16 * a as u16 + 127) / 255) as u8; + p[0] = m(p[0]); + p[1] = m(p[1]); + p[2] = m(p[2]); + } + } + premul_pixels(px) +} + +/// Premultiplied RGBA bytes as pixmap pixels, in the same allocation when its capacity is a whole +/// number of pixels and in a copy otherwise. +fn premul_pixels(px: Vec) -> Vec { + bytemuck::allocation::try_cast_vec(px).unwrap_or_else(|(_, px)| { + px.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect() + }) } /// Box-filter downsample by 2. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9e3d74e8b..8309667b2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -30,6 +30,36 @@ fn eps_source_decodes_like_its_preview() { assert!(image_size(corrupt).is_none()); } +#[test] +fn decoded_images_are_premultiplied_for_every_alpha() { + // Column x holds colour value x, row y holds alpha y. + let img = image::RgbaImage::from_fn(256, 256, |x, y| image::Rgba([x as u8, 255 - x as u8, (x / 2) as u8, y as u8])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img.clone()).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let pm = decode_pixmap(&png).unwrap(); + assert_eq!((pm.width(), pm.height()), (256, 256)); + for (p, q) in img.pixels().zip(pm.data()) { + let m = |c: u8| ((c as u16 * p[3] as u16 + 127) / 255) as u8; + assert_eq!([q.r, q.g, q.b, q.a], [m(p[0]), m(p[1]), m(p[2]), p[3]], "{p:?}"); + } +} + +#[test] +fn premultiplied_pixels_reuse_the_decoded_buffer() { + use vello_cpu::color::PremulRgba8; + let expected = + [PremulRgba8 { r: 10, g: 20, b: 30, a: 255 }, PremulRgba8 { r: 100, g: 50, b: 25, a: 128 }, PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }]; + let px = vec![10, 20, 30, 255, 200, 100, 50, 128, 1, 2, 3, 0]; + let ptr = px.as_ptr() as usize; + let out = decode::premultiply(px); + assert_eq!(out.as_ptr() as usize, ptr); + assert_eq!(out, expected); + // A capacity that is not a whole number of pixels is copied. + let mut px = Vec::with_capacity(13); + px.extend([10, 20, 30, 255, 200, 100, 50, 128, 1, 2, 3, 0]); + assert_eq!(decode::premultiply(px), expected); +} + #[test] fn footnote_vector_objects_are_painted() { use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef};