From 2b9aae64a43e529631beeb83789f1e350befcf23 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 03:07:52 +0200 Subject: [PATCH] Placed greyscale and bitmap images take the swatch applied to them, as in InDesign (IDML, screen, PDF) --- ROADMAP.md | 1 + apps/designcraft-cli/src/perf.rs | 1 + crates/doc/src/assets.rs | 1 + crates/doc/src/item.rs | 4 + crates/engine/src/cmd/datamerge/fill.rs | 10 +- crates/engine/src/cmd/export.rs | 2 + crates/engine/src/cmd/file.rs | 4 + crates/engine/src/cmd/links.rs | 2 + crates/engine/src/cmd/mathexpr.rs | 11 +- crates/engine/src/cmd/media.rs | 2 + crates/engine/src/cmd/table.rs | 1 + crates/engine/src/sample.rs | 1 + crates/epub/src/lib.rs | 3 + crates/format/src/lib.rs | 1 + crates/idml/src/export.rs | 7 + crates/idml/src/import.rs | 17 ++ crates/idml/src/tests/assets.rs | 51 +++++ crates/images/src/lib.rs | 2 + crates/images/src/tone.rs | 201 ++++++++++++++++++ crates/pdf/src/export.rs | 80 +++++++ crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 46 ++++ .../support/image_content_cache_contract.rs | 1 + crates/pptx/src/tests.rs | 1 + crates/render/src/colorize.rs | 109 ++++++++++ crates/render/src/lib.rs | 10 +- crates/render/tests/colorize.rs | 102 +++++++++ crates/render/tests/placed_pdf.rs | 11 +- crates/tools/src/tests.rs | 2 + crates/ui-egui/src/panels/properties.rs | 1 + docs/gaps.md | 6 +- docs/target-app-parity.md | 4 +- 32 files changed, 684 insertions(+), 12 deletions(-) create mode 100644 crates/images/src/tone.rs create mode 100644 crates/render/src/colorize.rs create mode 100644 crates/render/tests/colorize.rs diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..d297d1cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 7749f644..c1b798cc 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -246,6 +246,7 @@ pub fn synthetic(spec: &Spec) -> Result { auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); if k == 0 && pi == 0 { it.effects.drop_shadow.on = true; diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index cff196fc..834b36e4 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -349,6 +349,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [crop; 4], + fill: Default::default(), }); it } diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index fdae7881..ca6af050 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -520,6 +520,10 @@ pub struct Graphic { /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). #[serde(default)] pub crop: [f64; 4], + /// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with + /// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is. + #[serde(default, skip_serializing_if = "Fill::is_none")] + pub fill: Fill, } fn center_ref() -> u8 { diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 6ecb1470..f5b03253 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -381,7 +381,15 @@ fn place_image( pixels: Some((pw, ph)), page: 0, }); - let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; + let mut g = Graphic { + asset: aid, + size: (nw, nh), + xf: Affine::translate((inner.x0, inner.y0)), + auto_fit: mode, + fit_align: 0, + crop: [0.0; 4], + fill: Default::default(), + }; if let Some(xf) = g.fitted(inner, mode) { g.xf = xf; } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f10f3037..64329fff 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -385,6 +385,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); it.stroke.weight = 0.0; it @@ -550,6 +551,7 @@ fn rasterize_where( auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); img_item.stroke.weight = 0.0; img_item.alt_text = it.alt_text.clone(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f63f0d6e..c3c4db2d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -400,6 +400,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); fid } else { @@ -418,6 +419,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(spread, it, None)?; id @@ -525,6 +527,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); fid } @@ -549,6 +552,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(sr, it, None)?; id diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 9db6c6cb..667d655f 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -605,6 +605,7 @@ mod shared_asset_tests { auto_fit: Default::default(), fit_align: 4, crop: [1.0; 4], + fill: Default::default(), }); src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); @@ -647,6 +648,7 @@ mod shared_asset_tests { auto_fit: Default::default(), fit_align: 4, crop: [1.0; 4], + fill: Default::default(), }); src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index d9ce5114..ebeb3538 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -26,8 +26,15 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - it.content = - Content::Graphic(Graphic { asset: aid, size: (w, h), xf: Affine::translate(at), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + it.content = Content::Graphic(Graphic { + asset: aid, + size: (w, h), + xf: Affine::translate(at), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + fill: Default::default(), + }); it.alt_text = latex.to_string(); it.name = "Math".into(); it diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index f6d13ec0..3c3af87f 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -38,6 +38,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); it.media = Some(MediaOptions { controls: true, ..Default::default() }); fid @@ -54,6 +55,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); it.media = Some(MediaOptions { controls: true, ..Default::default() }); d.insert_item(spread, it, None)?; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4b2962b4..93c07e38 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1422,6 +1422,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); clamp_selection(d, sel); Ok(json!({"asset": aid.0, "row": r, "col": c})) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 15a05d08..448a64da 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -85,6 +85,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); it.object_style = designcraft_doc::BASIC_GRAPHICS_FRAME.into(); d.insert_item(sr, it, None)?; diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 32e327aa..5e62efbb 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -1538,6 +1538,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); item }; @@ -1892,6 +1893,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); aid.0 @@ -2006,6 +2008,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 86393f80..7c87dd9e 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -336,6 +336,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [crop; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); id diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 75f28a33..06a0233c 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1888,6 +1888,13 @@ impl<'a> Ex<'a> { el.set("ActualPpi", pt(pw as f64 * 72.0 / g.size.0, ph as f64 * 72.0 / g.size.1)); } el.set("ItemTransform", affine_s(g.xf)); + // The swatch that colours a greyscale or bitmap image; written only when it exists. + if !g.fill.is_none() + && let Some(id) = self.swatch_ids.get(&g.fill.swatch) + { + el.set("FillColor", id.clone()); + el.set("FillTint", pct(g.fill.tint as f64)); + } let embed = !asset.data.is_empty() && (self.opts.embed_images || asset.link.is_none()); let mut props = Vec::new(); if embed { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9fd519ef..84d2d3de 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2507,6 +2507,10 @@ impl<'r> Importer<'r> { _ => { if let Some(g) = e.elements().find(|c| matches!(c.local(), "Image" | "PDF" | "EPS" | "ImportedPage" | "WMF" | "PICT" | "SVG")) { it.content = self.graphic(g); + let fill = self.image_fill(g); + if let Content::Graphic(gr) = &mut it.content { + gr.fill = fill; + } if let (Some(ff), Content::Graphic(gr)) = (e.elements().find(|c| c.local() == "FrameFittingOption"), &mut it.content) { gr.crop = [ff.num("TopCrop"), ff.num("LeftCrop"), ff.num("BottomCrop"), ff.num("RightCrop")].map(|v| v.unwrap_or(0.0)); gr.fit_align = ff.get("FittingAlignment").map_or(4, names::anchor_in); @@ -2534,6 +2538,18 @@ impl<'r> Importer<'r> { Some(it) } + /// The swatch applied to a placed image (`FillColor`, `FillTint`): it colours a greyscale or + /// bitmap image. Black at 100% is how InDesign writes an image nobody coloured: no swatch. + fn image_fill(&mut self, g: &El) -> Fill { + let Some(r) = g.get("FillColor") else { return Fill::none() }; + let swatch = self.swatch_ref(r); + let tint = tint(g.num("FillTint")).unwrap_or(1.0); + if swatch == swatch::BLACK && tint >= 1.0 { + return Fill::none(); + } + Fill { swatch, tint, ..Fill::none() } + } + fn graphic(&mut self, g: &El) -> Content { let gxf = parse_affine(g.get("ItemTransform")); let (l, t, r, b) = match g.prop_el("GraphicBounds") { @@ -2553,6 +2569,7 @@ impl<'r> Importer<'r> { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }) }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 6817208c..f15af177 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -191,3 +191,54 @@ fn a_pdf_placement_reads_its_page_number() { assert_eq!(pages, vec![Some(1), Some(0), Some(1)], "embed_images: {embed_images}"); } } + +/// The swatch applied to a placed image (``) colours a greyscale or +/// bitmap image in InDesign (#1094). Black at 100%, InDesign's own value for an image nobody +/// coloured, and None leave the image as it is, like a missing attribute. +#[test] +fn the_swatch_applied_to_an_image_is_kept() { + let bytes = vec![42u8; 300]; + let reader = |p: &str| p.ends_with("couple.jpg").then(|| bytes.clone()); + let coloured = |n: usize, attrs: &str| image(n, PHOTO, None).replacen(&format!(r#"Self="im{n}""#), &format!(r#"Self="im{n}" {attrs}"#), 1); + let frames = vec![ + frame(1, 0.0, 0.0, &coloured(1, r#"FillColor="Color/Brand" FillTint="50""#)), + frame(2, 100.0, 0.0, &coloured(2, r#"FillColor="Color/Black" FillTint="-1""#)), + frame(3, 200.0, 0.0, &coloured(3, r#"FillColor="Swatch/None""#)), + frame(4, 300.0, 0.0, &image(4, PHOTO, None)), + frame(5, 400.0, 0.0, &coloured(5, r#"FillColor="Color/u9""#)), + frame(6, 500.0, 0.0, &coloured(6, r#"FillColor="Color/Black" FillTint="40""#)), + ]; + let fills = |d: &Document| { + let mut v: Vec<(f64, (String, f32))> = d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => Some((i.xf.translation().x, (g.fill.swatch.clone(), g.fill.tint))), + _ => None, + }) + .collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.into_iter().map(|(_, f)| f).collect::>() + }; + let none = || (designcraft_color::swatch::NONE.to_string(), 1.0); + let want = vec![ + ("Brand".to_string(), 0.5), + none(), + none(), + none(), + ("C=10 M=20 Y=30 K=40".to_string(), 1.0), + (designcraft_color::swatch::BLACK.to_string(), 0.4), + ]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + assert_eq!(fills(&d), want); + + let out = export_idml_with(&d, &ExportOptions::default()); + let spreads = zip_text(&out, "Spreads/"); + let images: Vec<&str> = spreads.split("').next().unwrap_or("")).collect(); + assert_eq!(images.len(), 6); + // Only an image with a swatch says so; none of them gets an invented `Swatch/None`. + assert_eq!(images.iter().filter(|e| e.contains("FillColor=")).count(), 3, "{images:?}"); + assert!(images.iter().any(|e| e.contains(r#"FillColor="Color/Brand""#) && e.contains(r#"FillTint="50""#)), "{images:?}"); + assert!(!images.iter().any(|e| e.contains("Swatch/None")), "{images:?}"); + assert_eq!(fills(&import_idml_with(&out, &reader).unwrap()), want); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 28a42522..daa50829 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -10,7 +10,9 @@ pub use resvg::usvg; mod eps; mod psd; +mod tone; pub use eps::{bounding_box as eps_bounding_box, eps_proxy, is_eps}; +pub use tone::{Tone, tone}; /// CSS pixels (SVG user units) → points. pub const PT_PER_PX: f64 = 0.75; diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs new file mode 100644 index 00000000..10be6844 --- /dev/null +++ b/crates/images/src/tone.rs @@ -0,0 +1,201 @@ +//! Which placed rasters a swatch can colour. InDesign colours a greyscale or a bitmap (1-bit) +//! image with the swatch applied to it; colour images, and images with alpha or spot channels, +//! keep their own colours. + +/// The kind of image a swatch colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Tone { + /// Each pixel is a tint of the swatch (its darkness); white is 0%, opaque. + Gray, + /// Black pixels take the swatch, white ones are transparent. + Bitmap, +} + +/// The [`Tone`] of an image file, `None` when a swatch doesn't colour it. +pub fn tone(bytes: &[u8]) -> Option { + use image::{ExtendedColorType as E, ImageDecoder, ImageFormat as F}; + if crate::is_psd(bytes) { + // Header: channels at 12, colour mode at 24 (big-endian). A second channel is alpha or spot. + let be = |i: usize| Some(u16::from_be_bytes([*bytes.get(i)?, *bytes.get(i + 1)?])); + return match (be(12)?, be(24)?) { + (1, 0) => Some(Tone::Bitmap), + (1, 1) => Some(Tone::Gray), + _ => None, + }; + } + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + match reader.format()? { + F::Png => { + // `image` expands 1-bit greys to 8 bits: the depth is IHDR's (signature, length, type, + // width, height, then the bit depth). + let gray = matches!(reader.into_decoder().ok()?.color_type(), image::ColorType::L8 | image::ColorType::L16); + let one_bit = *bytes.get(24)? == 1; + gray.then_some(if one_bit { Tone::Bitmap } else { Tone::Gray }) + } + F::Tiff | F::Jpeg | F::Bmp => match reader.into_decoder().ok()?.original_color_type() { + E::L1 => Some(Tone::Bitmap), + E::L2 | E::L4 | E::L8 | E::L16 => Some(Tone::Gray), + _ => None, + }, + // Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap. + F::Gif => { + let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8(); + let mut opaque = true; + let mut two_level = true; + for p in img.pixels() { + let [r, g, b, a] = p.0; + match a { + 0 => opaque = false, + 255 if r == g && g == b => two_level &= r == 0 || r == 255, + _ => return None, + } + } + if two_level { Some(Tone::Bitmap) } else { opaque.then_some(Tone::Gray) } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ExtendedColorType, ImageEncoder}; + + fn encode(px: &[u8], w: u32, h: u32, ct: ExtendedColorType, f: image::ImageFormat) -> Vec { + let mut b = Vec::new(); + let c = std::io::Cursor::new(&mut b); + match f { + image::ImageFormat::Png => image::codecs::png::PngEncoder::new(c).write_image(px, w, h, ct).unwrap(), + image::ImageFormat::Tiff => image::codecs::tiff::TiffEncoder::new(c).write_image(px, w, h, ct).unwrap(), + image::ImageFormat::Jpeg => image::codecs::jpeg::JpegEncoder::new(c).write_image(px, w, h, ct).unwrap(), + image::ImageFormat::Gif => image::codecs::gif::GifEncoder::new(c).encode(px, w, h, ct).unwrap(), + f => panic!("no encoder for {f:?} here"), + } + b + } + + fn crc32(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + + /// A 1-bit greyscale PNG, one row of up to 8 pixels (`bits`, MSB first, 1 = white), written + /// by hand: `image` only writes 8- and 16-bit PNGs. + fn png_1bit(w: u32, bits: u8) -> Vec { + let chunk = |out: &mut Vec, kind: &[u8; 4], body: &[u8]| { + out.extend(u32::try_from(body.len()).unwrap().to_be_bytes()); + let start = out.len(); + out.extend(kind); + out.extend(body); + let crc = crc32(&out[start..]); + out.extend(crc.to_be_bytes()); + }; + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &[&w.to_be_bytes()[..], &1u32.to_be_bytes(), &[1, 0, 0, 0, 0]].concat()); + // zlib, one stored deflate block holding the filter byte and the row. + let raw = [0u8, bits]; + let (mut a, mut b) = (1u32, 0u32); + for &v in &raw { + a = (a + u32::from(v)) % 65_521; + b = (b + a) % 65_521; + } + let z = [&[0x78, 0x01, 0x01, 2, 0, 0xFD, 0xFF][..], &raw, &((b << 16) | a).to_be_bytes()].concat(); + chunk(&mut png, b"IDAT", &z); + chunk(&mut png, b"IEND", &[]); + png + } + + /// A 1-bit TIFF (BlackIsZero), one row of 8 pixels, uncompressed. + fn tiff_1bit(bits: u8) -> Vec { + let entries: [(u16, u16, u32); 8] = [ + (256, 3, 8), // width + (257, 3, 1), // height + (258, 3, 1), // bits per sample + (259, 3, 1), // no compression + (262, 3, 1), // BlackIsZero + (273, 4, 8), // strip offset + (278, 3, 1), // rows per strip + (279, 4, 1), // strip byte count + ]; + let mut t = b"II*\0".to_vec(); + t.extend(10u32.to_le_bytes()); + t.extend([bits, 0]); + t.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, kind, value) in entries { + t.extend(tag.to_le_bytes()); + t.extend(kind.to_le_bytes()); + t.extend(1u32.to_le_bytes()); + if kind == 3 { + t.extend(u16::try_from(value).unwrap().to_le_bytes()); + t.extend([0, 0]); + } else { + t.extend(value.to_le_bytes()); + } + } + t.extend(0u32.to_le_bytes()); + t + } + + /// A PSD header: `channels` channels in colour `mode`, 8 bits deep (1 for bitmap). + fn psd_header(channels: u16, mode: u16) -> Vec { + let mut b = b"8BPS".to_vec(); + b.extend(1u16.to_be_bytes()); + b.extend([0; 6]); + b.extend(channels.to_be_bytes()); + b.extend(2u32.to_be_bytes()); + b.extend(2u32.to_be_bytes()); + b.extend((if mode == 0 { 1u16 } else { 8 }).to_be_bytes()); + b.extend(mode.to_be_bytes()); + b + } + + #[test] + fn greyscale_and_bitmap_files_take_a_swatch() { + use image::ImageFormat::{Gif, Jpeg, Png, Tiff}; + let grey = [0u8, 128, 255, 64]; + assert_eq!(tone(&encode(&grey, 2, 2, ExtendedColorType::L8, Png)), Some(Tone::Gray)); + assert_eq!(tone(&encode(&grey, 2, 2, ExtendedColorType::L8, Tiff)), Some(Tone::Gray)); + assert_eq!(tone(&encode(&grey, 2, 2, ExtendedColorType::L8, Jpeg)), Some(Tone::Gray)); + let wide: Vec = grey.iter().flat_map(|&v| (u16::from(v) * 257).to_ne_bytes()).collect(); + assert_eq!(tone(&encode(&wide, 2, 2, ExtendedColorType::L16, Png)), Some(Tone::Gray)); + assert_eq!(tone(&png_1bit(8, 0b1010_0000)), Some(Tone::Bitmap)); + assert_eq!(tone(&tiff_1bit(0b1100_0000)), Some(Tone::Bitmap)); + assert_eq!(tone(&psd_header(1, 1)), Some(Tone::Gray)); + assert_eq!(tone(&psd_header(1, 0)), Some(Tone::Bitmap)); + + // GIFs are indexed: black and white only is a bitmap, neutral greys a greyscale image. + let rgba = |vals: &[[u8; 4]]| vals.concat(); + let bw = rgba(&[[0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 255, 0]]); + assert_eq!(tone(&encode(&bw, 2, 2, ExtendedColorType::Rgba8, Gif)), Some(Tone::Bitmap)); + let greys = rgba(&[[0, 0, 0, 255], [128, 128, 128, 255], [200, 200, 200, 255], [255, 255, 255, 255]]); + assert_eq!(tone(&encode(&greys, 2, 2, ExtendedColorType::Rgba8, Gif)), Some(Tone::Gray)); + } + + #[test] + fn colour_and_alpha_files_keep_their_colours() { + use image::ImageFormat::{Gif, Png}; + // Neutral pixels in an RGB file: still a colour image. + let rgb = [0u8, 0, 0, 255, 255, 255, 128, 128, 128, 9, 9, 9]; + assert_eq!(tone(&encode(&rgb, 2, 2, ExtendedColorType::Rgb8, Png)), None); + // Greyscale with an alpha channel. + let la = [0u8, 255, 128, 128, 255, 255, 64, 0]; + assert_eq!(tone(&encode(&la, 2, 2, ExtendedColorType::La8, Png)), None); + assert_eq!(tone(&psd_header(2, 1)), None); + assert_eq!(tone(&psd_header(3, 3)), None); + // A GIF with a colour in its palette, and grey levels with transparency. + let red = [[255u8, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]].concat(); + assert_eq!(tone(&encode(&red, 2, 2, ExtendedColorType::Rgba8, Gif)), None); + let see_through = [[128u8, 128, 128, 255], [0, 0, 0, 0], [255, 255, 255, 255], [0, 0, 0, 255]].concat(); + assert_eq!(tone(&encode(&see_through, 2, 2, ExtendedColorType::Rgba8, Gif)), None); + // Not an image, a truncated header, an SVG. + for junk in [&b""[..], b"8BPS\0\x01", b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR", b""] { + assert_eq!(tone(junk), None); + } + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index b76af6ec..c7b91f90 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1372,12 +1372,92 @@ impl Exporter<'_> { } return; } + if self.colorized(s, g) { + return; + } let Some(img) = self.load_image(g.asset) else { return }; let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return }; s.push_transform(&tf(g.xf)); s.draw_image(img, size); s.pop(); } + + /// A greyscale or bitmap image coloured by the swatch applied to it (#1094): the swatch is + /// painted through the image's ink coverage (its darkness), over 0% ink for a greyscale + /// image, whose white is opaque. `false`, drawing nothing, when the image keeps its own + /// colours: no swatch, one that no longer exists, a colour image. + fn colorized(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) -> bool { + if g.fill.is_none() { + return false; + } + let Some(asset) = self.doc.assets.get(&g.asset) else { return false }; + let Some(tone) = designcraft_images::tone(&asset.data) else { return false }; + let Some(paint) = self.swatch_color(&g.fill.swatch, g.fill.tint) else { return false }; + let Some(coverage) = designcraft_images::decode_rgba(&asset.data).and_then(|img| CoverageImage::new(&img)) else { return false }; + let interpolate = RasterMode::for_standard(self.opts.standard).interpolate && self.interpolate; + let Ok(mask) = Image::from_custom(coverage, interpolate) else { return false }; + let size = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32); + let (Some(size), Some(rect)) = (size, to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))) else { return false }; + s.push_transform(&tf(g.xf)); + if tone == designcraft_images::Tone::Gray { + let blank = + self.swatch_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or_else(|| device(&Color::cmyk(0.0, 0.0, 0.0, 0.0), self.rgb_only)); + s.set_fill(Some(Fill { paint: blank.into(), ..Default::default() })); + s.draw_path(&rect); + } + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + ms.draw_image(mask, size); + ms.finish(); + let stream = sb.finish(); + s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); + s.set_fill(Some(Fill { paint: paint.into(), ..Default::default() })); + s.draw_path(&rect); + s.pop(); + s.set_fill(None); + s.pop(); + true + } +} + +/// The ink coverage of a greyscale or bitmap image (255 = black, 0 = white or transparent) as a +/// DeviceGray image, painted into a luminosity mask. +#[derive(Clone, Hash)] +struct CoverageImage { + grey: Arc>, + size: (u32, u32), +} + +impl CoverageImage { + fn new(rgba: &image::RgbaImage) -> Option { + let size = rgba.dimensions(); + if size.0 == 0 || size.1 == 0 { + return None; + } + let grey = rgba.pixels().map(|p| ((u16::from(255 - p[0]) * u16::from(p[3]) + 127) / 255) as u8).collect(); + Some(Self { grey: Arc::new(grey), size }) + } +} + +impl krilla::image::CustomImage for CoverageImage { + fn color_channel(&self) -> &[u8] { + &self.grey + } + fn alpha_channel(&self) -> Option<&[u8]> { + None + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Luma + } } /// `data` with its `%PDF-x.y` header (within the first 1024 bytes, where readers look for it) diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index ebb3d12d..99978e5e 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -481,6 +481,7 @@ mod tests { auto_fit: Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2badf714..47534b34 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -248,6 +248,7 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } @@ -568,3 +569,48 @@ fn empty_and_populated_export_metadata_preserve_key_presence() { } assert!(s.contains("Synthetic title") && s.contains("Test author")); } + +/// A swatch applied to a placed greyscale image colours it (#1094): the image's darkness is the +/// coverage of the swatch, so a spot swatch prints on its own plate. A colour image, or a swatch +/// that no longer exists, exports as the image itself. +#[test] +fn a_swatch_colours_a_greyscale_image() { + let encode = |px: &[u8], ct: image::ExtendedColorType| { + let mut b = Vec::new(); + image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut b), px, 3, 1, ct).unwrap(); + b + }; + let grey = encode(&[0, 64, 255], image::ExtendedColorType::L8); + let rgb = encode(&[0, 0, 0, 200, 40, 40, 255, 255, 255], image::ExtendedColorType::Rgb8); + let export = |file: &[u8], swatch: Option<&str>| { + let mut d = doc_with_text("x"); + d.swatches.push(Swatch { + name: "PANTONE Test".into(), + value: SwatchValue::Color { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), color_type: ColorType::Spot }, + locked: false, + named: true, + hidden: false, + }); + place(&mut d, file.to_vec(), (3, 1)); + if let Some(sw) = swatch { + let id = d.spreads[0].items.last().unwrap().id; + if let designcraft_doc::Content::Graphic(g) = &mut d.item_mut(id).unwrap().content { + g.fill = Fill::swatch(sw); + } + } + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { compress: false, ..Default::default() }).unwrap(); + (String::from_utf8_lossy(&bytes).into_owned(), image_xobjects(&bytes)) + }; + let (plain, plain_images) = export(&grey, None); + assert!(!plain.contains("PANTONE#20Test"), "the spot is used by nothing else"); + let (s, images) = export(&grey, Some("PANTONE Test")); + assert!(s.contains("/Separation") && s.contains("/PANTONE#20Test"), "the image prints on the spot plate"); + // The swatch is painted through the image's coverage: black is full ink, white none. + assert!(images.contains(&("DeviceGray".to_string(), vec![255, 191, 0])), "{images:?}"); + + let (gone, gone_images) = export(&grey, Some("Gone")); + assert_eq!(gone_images, plain_images, "a deleted swatch: the image as it is"); + assert!(!gone.contains("/SMask"), "nothing painted through it"); + let (s, _) = export(&rgb, Some("PANTONE Test")); + assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours"); +} diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index c7812ce7..dfcd1ad7 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -36,6 +36,7 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); } diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index 98e78384..f116d46d 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -218,6 +218,7 @@ fn pictures_are_cropped_to_their_frames() { auto_fit: designcraft_doc::Fitting::None, fit_align: 0, crop: [0.0; 4], + fill: Default::default(), }); }); let mut z = export(&d, &PptxOptions::default()); diff --git a/crates/render/src/colorize.rs b/crates/render/src/colorize.rs new file mode 100644 index 00000000..0a13a736 --- /dev/null +++ b/crates/render/src/colorize.rs @@ -0,0 +1,109 @@ +//! A swatch applied to a placed greyscale or bitmap image (#1094). A greyscale pixel becomes the +//! swatch at the pixel's ink coverage (its darkness), white staying opaque; a bitmap's black +//! takes the swatch and its white turns transparent, so the frame's fill shows through. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Mutex, PoisonError}; + +use designcraft_color::swatch::apply_tint; +use designcraft_doc::{Document, Fill}; +use designcraft_images::Tone; +use vello_cpu::Pixmap; +use vello_cpu::color::PremulRgba8; + +/// The tone of each image file a swatch was applied to, by the address of its bytes (kept alive +/// so the address stays theirs). A GIF is decoded to know, so this is not done every frame. +fn tone_of(bytes: &Arc>) -> Option { + type Tones = HashMap>, Option)>; + const MAX: usize = 256; + static TONES: Mutex> = Mutex::new(None); + let key = Arc::as_ptr(bytes) as usize; + if let Some((_, t)) = TONES.lock().unwrap_or_else(PoisonError::into_inner).as_ref().and_then(|m| m.get(&key)) { + return *t; + } + let tone = designcraft_images::tone(bytes); + let mut guard = TONES.lock().unwrap_or_else(PoisonError::into_inner); + let map = guard.get_or_insert_with(HashMap::new); + if map.len() >= MAX { + map.clear(); + } + map.insert(key, (bytes.clone(), tone)); + tone +} + +/// `pm` (decoded from `bytes`) coloured by `fill`, cached per image and on-screen colour. `None` +/// when the image keeps its own colours: no swatch, one that no longer exists, or a colour image. +pub(crate) fn colorized(doc: &Document, fill: &Fill, bytes: &Arc>, pm: &Arc) -> Option> { + if fill.is_none() { + return None; + } + let color = doc.resolve_color(&fill.swatch, fill.tint)?; + let tone = tone_of(bytes)?; + // The swatch at each ink coverage as the screen shows it (or the plate being previewed). + let lut: Vec<[u8; 4]> = (0..=255u8) + .map(|i| { + let c = crate::color_of(&apply_tint(color, f32::from(i) / 255.0), 1.0).to_rgba8(); + [c.r, c.g, c.b, c.a] + }) + .collect(); + let mut h = DefaultHasher::new(); + lut.hash(&mut h); + let key = (Arc::as_ptr(pm) as usize, tone, h.finish()); + + // As for plate previews: source pixmaps are kept alive (their addresses are keys), new colour + // settings start over, and the derived pixels have a memory budget. + type Entry = (Arc, Arc); + type Cache = (designcraft_color::cms::ColorSettings, HashMap<(usize, Tone, u64), Entry>, usize); + const BUDGET: usize = 64 << 20; + static CACHE: Mutex> = Mutex::new(None); + let settings = designcraft_color::cms::active_settings(); + { + let mut guard = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((previous, map, _)) = guard.as_ref() { + if *previous == settings { + if let Some((_, derived)) = map.get(&key) { + return Some(derived.clone()); + } + } else { + *guard = None; + } + } + } + let premul = |[r, g, b, _]: [u8; 4], a: u8| { + let m = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8; + PremulRgba8 { r: m(r), g: m(g), b: m(b), a } + }; + let solid = lut.last().copied().unwrap_or([0, 0, 0, 255]); + let data: Vec = pm + .data() + .iter() + .map(|p| { + if p.a == 0 { + return *p; + } + // The image is grey: its red is its grey level (premultiplied). + let grey = (u16::from(p.r) * 255 / u16::from(p.a)).min(255); + let coverage = 255 - grey; + match tone { + Tone::Gray => premul(lut.get(usize::from(coverage)).copied().unwrap_or(solid), p.a), + Tone::Bitmap => premul(solid, ((coverage * u16::from(p.a) + 127) / 255) as u8), + } + }) + .collect(); + let out = Arc::new(Pixmap::from_parts(data, pm.width(), pm.height())); + let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8); + if bytes <= BUDGET { + let mut guard = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), HashMap::new(), 0)); + if *previous != settings || used.saturating_add(bytes) > BUDGET { + map.clear(); + *used = 0; + *previous = settings; + } + if map.insert(key, (pm.clone(), out.clone())).is_none() { + *used += bytes; + } + } + Some(out) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6c13d220..64b41611 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; pub use vello_cpu; mod blendspace; +mod colorize; pub mod damage; mod fx; @@ -818,9 +819,11 @@ impl Renderer { let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; let Some(pm) = images::mip(&data, asset.page, g.size.0, on_screen) else { return }; - let pm = match PLATE.with(|p| p.get()) { - Some(plate) => plate_pixmap(&pm, plate), - None => pm, + // A coloured image is already in the plate's inks (its colours come from `color_of`). + let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(|p| p.get())) { + (Some(coloured), _) => coloured, + (None, Some(plate)) => plate_pixmap(&pm, plate), + (None, None) => pm, }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); @@ -1660,6 +1663,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let cache = Cache::new(); diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/colorize.rs new file mode 100644 index 00000000..6ac0256c --- /dev/null +++ b/crates/render/tests/colorize.rs @@ -0,0 +1,102 @@ +//! A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign (#1094): +//! a greyscale pixel is a tint of the swatch as strong as the pixel is dark, and white stays +//! opaque; a bitmap's black takes the swatch and its white lets the frame's fill show through. +//! Colour images, and swatches that no longer exist, leave the image as it is. + +use std::sync::Arc; + +use designcraft_color::swatch::apply_tint; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, Shape, SpreadRef, Stroke}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{RenderOptions, Renderer, color_of}; +use image::{ExtendedColorType, ImageEncoder}; + +const RED: &str = "C=0 M=100 Y=100 K=0"; +const CYAN: &str = "C=100 M=0 Y=0 K=0"; + +fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec { + let mut b = Vec::new(); + let c = std::io::Cursor::new(&mut b); + if gif { + image::codecs::gif::GifEncoder::new(c).encode(px, w, 1, ct).unwrap(); + } else { + image::codecs::png::PngEncoder::new(c).write_image(px, w, 1, ct).unwrap(); + } + b +} + +/// The pixels at the centres of the image's three columns, placed 150 × 50 pt at (100, 100) in a +/// cyan frame, with `fill` applied to the image. +fn shown(file: Vec, fill: Fill) -> Vec<[u8; 4]> { + let mut d = Document::new(&NewDocument::default()); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { page: 0, id: aid, name: "a".into(), mime: "image/png".into(), link: None, data: Arc::new(file), pixels: Some((3, 1)) }), + ); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 150.0))); + it.fill = Fill::swatch(CYAN); + it.stroke = Stroke::default(); + it.content = Content::Graphic(Graphic { + asset: aid, + size: (150.0, 50.0), + xf: Affine::translate((100.0, 100.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + fill, + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &Cache::new(), 0, 1.0, false, &RenderOptions::default()).unwrap(); + [125, 175, 225].iter().map(|&x| img.pixel(x, 125)).collect() +} + +/// The swatch `name` at `tint` on screen. +fn swatch_rgb(name: &str, tint: f32) -> [u8; 4] { + let d = Document::new(&NewDocument::default()); + let [r, g, b, a] = color_of(&apply_tint(d.resolve_color(name, 1.0).unwrap(), tint), 1.0).to_rgba8().to_u8_array(); + [r, g, b, a] +} + +fn near(a: [u8; 4], b: [u8; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 4) +} + +#[test] +fn a_greyscale_image_is_a_tint_of_the_swatch_on_opaque_white() { + let grey = encode(&[0, 128, 255], 3, ExtendedColorType::L8, false); + let got = shown(grey.clone(), Fill::swatch(RED)); + assert!(near(got[0], swatch_rgb(RED, 1.0)), "black is the swatch: {got:?}"); + assert!(near(got[1], swatch_rgb(RED, 127.0 / 255.0)), "mid grey is half the swatch: {got:?}"); + // White is 0% of the swatch, opaque: the cyan frame does not show through. + assert!(near(got[2], swatch_rgb(RED, 0.0)), "white stays white: {got:?}"); + assert!(got[2][0] > 240, "{got:?}"); + + // The swatch's tint scales every pixel's. + let half = shown(grey, Fill { tint: 0.5, ..Fill::swatch(RED) }); + assert!(near(half[0], swatch_rgb(RED, 0.5)), "{half:?}"); +} + +#[test] +fn a_bitmap_image_shows_the_frame_through_its_white() { + let bw = [[0u8, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]].concat(); + let got = shown(encode(&bw, 3, ExtendedColorType::Rgba8, true), Fill::swatch(RED)); + assert!(near(got[0], swatch_rgb(RED, 1.0)) && near(got[2], got[0]), "{got:?}"); + assert!(near(got[1], swatch_rgb(CYAN, 1.0)), "the frame's cyan through the white: {got:?}"); +} + +#[test] +fn colour_images_and_missing_swatches_are_left_alone() { + let rgb = encode(&[0, 0, 0, 200, 40, 40, 255, 255, 255], 3, ExtendedColorType::Rgb8, false); + assert_eq!(shown(rgb.clone(), Fill::swatch(RED)), shown(rgb, Fill::none())); + // A swatch that was deleted (or never imported): the image as it is. + let grey = encode(&[0, 128, 255], 3, ExtendedColorType::L8, false); + let plain = shown(grey.clone(), Fill::none()); + assert_eq!(shown(grey, Fill::swatch("Gone")), plain); + assert!(plain[0][0] < 10, "the image is drawn: {plain:?}"); +} diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs index d7487086..3aac49ad 100644 --- a/crates/render/tests/placed_pdf.rs +++ b/crates/render/tests/placed_pdf.rs @@ -78,8 +78,15 @@ fn placed(size: f64, pdf: Vec) -> Rendered { d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), link: None, data: Arc::new(pdf), pixels: None, page: 0 }); let id = ItemId(d.alloc()); let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); - item.content = - Content::Graphic(Graphic { asset, size: (size, size), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + item.content = Content::Graphic(Graphic { + asset, + size: (size, size), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + fill: Default::default(), + }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); let mut renderer = Renderer::new(); renderer.threads = 0; diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index aacfb8d3..c069ee60 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1152,6 +1152,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); d.assets.insert( designcraft_doc::AssetId(1), @@ -1241,6 +1242,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); it }; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e68..b7ac40db 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2516,6 +2516,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + fill: Default::default(), }); items.push(id); assets.push(aid); diff --git a/docs/gaps.md b/docs/gaps.md index 21a18b36..a9b5d1c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -178,7 +178,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 19. Placed graphics depth (FF) - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; Edit Original / Edit With; PDF placed as editable vectors (#227, #24); - QuarkXPress import (#73). + QuarkXPress import (#73); applying a swatch to a selected greyscale or bitmap image (Direct + Selection + Swatches). A swatch already on the image (IDML ``) colours it on + screen and in PDF and IDML export (#1094). - **Estimate:** 30–60 h (PostScript interpreter is a large separate project: +80–150 h). ### 20. Performance on large documents (P) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 62ba7f08..25ff4a34 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -424,7 +424,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. -- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. +- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.