From 4e363e1bfacc4a2c3b36f4b7f5c56afcef94833c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:29:50 -0800 Subject: [PATCH] Render: placed images are premultiplied in their decoded buffer Decoding a placed raster built a second full-size pixel vector from the RGBA image and premultiplied every pixel into it. The pixels are now premultiplied in place, opaque pixels are skipped, and the buffer becomes the pixmap's pixels without a copy (bytemuck's cast_vec, with a copy when the capacity is not a whole number of pixels). The rendered SVG path reuses its buffer the same way. For a 6000 x 4000 opaque image the step takes 7 ms instead of 120 ms, with identical pixels. --- Cargo.lock | 1 + Cargo.toml | 2 ++ ROADMAP.md | 1 + crates/render/Cargo.toml | 1 + crates/render/src/decode.rs | 38 ++++++++++++++++++++++++++----------- crates/render/src/tests.rs | 30 +++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 11 deletions(-) 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};