From b62a7956029feb21c830c4aed6df3e380c876619 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:31:01 -0400 Subject: [PATCH] M9: render perf (glyph path cache, culling) + soft drop shadow/feather/glow; perf harness Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 6 + Cargo.lock | 5 + apps/designcraft-cli/Cargo.toml | 5 + apps/designcraft-cli/src/main.rs | 8 +- apps/designcraft-cli/src/perf.rs | 557 ++++++++++++++++++ crates/doc/src/item.rs | 55 ++ crates/render/src/fx.rs | 204 +++++++ crates/render/src/images.rs | 134 +++++ crates/render/src/lib.rs | 229 +++---- crates/render/src/text.rs | 264 +++++++++ crates/render/tests/effects.rs | 224 +++++++ .../tests/golden/drop-shadow-spread.png | Bin 0 -> 1461 bytes .../render/tests/golden/drop-shadow-text.png | Bin 0 -> 10988 bytes crates/render/tests/golden/drop-shadow.png | Bin 0 -> 3156 bytes crates/render/tests/golden/feather.png | Bin 0 -> 3353 bytes crates/render/tests/golden/inner-shadow.png | Bin 0 -> 1867 bytes crates/render/tests/golden/outer-glow.png | Bin 0 -> 3367 bytes crates/render/tests/perf.rs | 122 ++++ 18 files changed, 1666 insertions(+), 147 deletions(-) create mode 100644 apps/designcraft-cli/src/perf.rs create mode 100644 crates/render/src/fx.rs create mode 100644 crates/render/src/images.rs create mode 100644 crates/render/src/text.rs create mode 100644 crates/render/tests/effects.rs create mode 100644 crates/render/tests/golden/drop-shadow-spread.png create mode 100644 crates/render/tests/golden/drop-shadow-text.png create mode 100644 crates/render/tests/golden/drop-shadow.png create mode 100644 crates/render/tests/golden/feather.png create mode 100644 crates/render/tests/golden/inner-shadow.png create mode 100644 crates/render/tests/golden/outer-glow.png create mode 100644 crates/render/tests/perf.rs diff --git a/ASSETS.md b/ASSETS.md index d267c642..97efe1cf 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -32,3 +32,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-typing.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | +| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | diff --git a/Cargo.lock b/Cargo.lock index 65113e70..74fb4ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,9 +843,14 @@ dependencies = [ name = "designcraft-cli" version = "0.1.0" dependencies = [ + "designcraft-color", + "designcraft-compose", "designcraft-doc", "designcraft-engine", + "designcraft-format", + "designcraft-geom", "designcraft-mcp", + "designcraft-pdf", "designcraft-render", "serde_json", ] diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index e354a1b8..7aaa454c 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -16,5 +16,10 @@ path = "src/main.rs" designcraft-engine = { workspace = true } designcraft-render = { workspace = true } designcraft-doc = { workspace = true } +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-compose = { workspace = true } +designcraft-format = { workspace = true } +designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 22dc1d63..2be0afc9 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -4,9 +4,13 @@ //! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml] [--pdf-options JSON] [--all-pages DIR] //! designcraft-cli commands # list every command (JSON) //! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) +//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document +//! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! ``` use std::process::ExitCode; +mod perf; + use designcraft_engine::Session; use serde_json::{Value, json}; @@ -20,9 +24,11 @@ fn main() -> ExitCode { ExitCode::SUCCESS } Some("mcp") => report(mcp(&args[1..])), + Some("perf") => report(perf::perf(&args[1..])), + Some("bench") => report(perf::bench(&args[1..])), _ => { eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]" + "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]" ); ExitCode::FAILURE } diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs new file mode 100644 index 00000000..7a393934 --- /dev/null +++ b/apps/designcraft-cli/src/perf.rs @@ -0,0 +1,557 @@ +//! `designcraft-cli perf` / `bench`: the performance budgets (plan/architecture.md §9) measured on +//! a synthetic stress document, or on a given file. +//! +//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load +//! average above ~¾ of the core count) they are noise, and the report says so. +//! +//! Zoom levels follow the canvas: 100% on a 2880×1800 HiDPI canvas is 2 device pixels per point. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +/// Device pixels per point at 100% (HiDPI canvas). +const HIDPI: f64 = 2.0; +const W: u32 = 2880; +const H: u32 = 1800; + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } + fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { + v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] + } +} + +const WORDS: &[&str] = &[ + "the", + "grid", + "page", + "margin", + "column", + "rhythm", + "baseline", + "reader", + "story", + "frame", + "quiet", + "layout", + "type", + "paragraph", + "composer", + "weighs", + "every", + "line", + "break", + "texture", + "even", + "hyphen", + "image", + "spread", + "pulse", + "caption", + "colour", + "swatch", + "ink", + "tint", + "gradient", + "style", + "voice", + "printer", + "proof", + "bleed", + "a", + "of", + "and", + "to", + "in", + "with", + "for", + "is", + "on", + "that", + "by", + "it", + "as", + "careful", + "measure", + "leading", + "kerning", + "tracking", + "glyph", + "serif", + "italic", + "heading", + "running", + "folio", +]; + +/// `chars` characters of original filler text in paragraphs of ~400 characters. +fn filler(r: &mut Rng, chars: usize) -> String { + let mut s = String::with_capacity(chars + 16); + let mut para = 0; + while s.len() < chars { + let w = r.pick(WORDS); + if para == 0 { + let mut c = w.chars(); + if let Some(f) = c.next() { + s.extend(f.to_uppercase()); + s.push_str(c.as_str()); + } + } else { + s.push_str(w); + } + para += w.len() + 1; + if para > 400 { + s.push_str(".\n"); + para = 0; + } else { + s.push(' '); + } + } + s +} + +/// Procedural RGB image, PNG-encoded. +fn art_png(w: u32, h: u32, seed: f32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); + } + } + designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() +} + +/// Parameters of the synthetic stress document. +pub struct Spec { + pub pages: usize, + pub frames_per_page: usize, + pub frames_per_story: usize, + pub chars: usize, + pub images: usize, + pub assets: usize, +} + +impl Default for Spec { + fn default() -> Self { + Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } + } +} + +/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text +/// frames, and a strip of placed images (one with a drop shadow per spread). +pub fn synthetic(spec: &Spec) -> Document { + let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); + let lid = d.default_layer(); + d.swatches.push(Swatch { + name: "Perf Gradient".into(), + value: SwatchValue::Gradient { + gradient: Gradient { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::cmyk(0.6, 0.9, 0.3, 0.2), opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.6, 0.8, 0.0), opacity: 1.0, midpoint: 0.5 }, + ], + }, + }, + locked: false, + named: true, + }); + let mut rng = Rng(0x5eed); + let (aw, ah) = (800u32, 600u32); + let assets: Vec = (0..spec.assets.max(1)) + .map(|k| { + let id = AssetId(d.alloc()); + let data = art_png(aw, ah, k as f32 * 0.37); + d.assets.insert( + id, + Arc::new(Asset { + id, + name: format!("art-{k}.png"), + mime: "image/png".into(), + link: None, + data: Arc::new(data), + pixels: Some((aw, ah)), + }), + ); + id + }) + .collect(); + let total_frames = spec.pages * spec.frames_per_page; + let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); + let per_story = spec.chars / stories.max(1); + let rows = spec.frames_per_page.div_ceil(2); + let (top, strip) = (48.0, 130.0); + let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; + let mut prev: Option = None; + let mut n_frame = 0usize; + let mut placed_images = 0usize; + for abs in 0..spec.pages { + let Some((si, pi)) = d.page_loc(abs) else { break }; + let sr = SpreadRef::Doc(si); + let x0 = d.spreads[si].pages[pi].x; + // Gradient band. + let id = ItemId(d.alloc()); + let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); + band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; + band.fill.gradient_angle = Some(rng.next() * 90.0); + d.insert_item(sr, band, None).expect("spread"); + // Text frames. + for k in 0..spec.frames_per_page { + let (c, r) = (k % 2, k / 2); + let fx = x0 + 36.0 + c as f64 * 280.0; + let fy = top + r as f64 * row_h; + let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); + let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); + let text = if first { filler(&mut rng, per_story) } else { String::new() }; + let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).expect("frame"); + if let (false, Some(p)) = (first, prev) { + d.thread(p, fid).expect("thread"); + } + prev = Some(fid); + n_frame += 1; + } + // Images: spread the requested count evenly over the pages. + let want = (spec.images * (abs + 1)) / spec.pages - placed_images; + for k in 0..want { + let w = 540.0 / want as f64; + let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); + let asset = assets[(placed_images + k) % assets.len()]; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + let (nw, nh) = (aw as f64, ah as f64); + let s = (r.width() / nw).max(r.height() / nh); + it.content = Content::Graphic(Graphic { + asset, + size: (nw, nh), + xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), + auto_fit: designcraft_doc::Fitting::FillProportionally, + }); + if k == 0 && pi == 0 { + it.effects.drop_shadow.on = true; + } + d.insert_item(sr, it, None).expect("spread"); + } + placed_images += want; + } + d +} + +fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { + let mut v: Vec = (0..runs.max(1)) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +/// 1-minute load average (macOS / Linux), if available. +fn load_average() -> Option { + let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success()); + let text = match out { + Some(o) => String::from_utf8_lossy(&o.stdout).to_string(), + None => std::fs::read_to_string("/proc/loadavg").ok()?, + }; + text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok()) +} + +/// Compose every story into a fresh cache on `threads` threads. +fn compose_all(doc: &Document, threads: usize) -> Arc { + let cache = Arc::new(Cache::new()); + let ids: Vec = doc.stories.keys().copied().collect(); + let next = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..threads.max(1) { + s.spawn(|| { + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(id) = ids.get(i) else { break }; + drop(cache.get(doc, *id, None)); + } + }); + } + }); + cache +} + +/// All spreads stacked vertically, centred on x = 0 (like the canvas). +fn layout(doc: &Document) -> Vec<(Placed, Rect)> { + let mut y = 0.0; + doc.spreads + .iter() + .enumerate() + .map(|(i, sp)| { + let b = sp.bounds(); + let offset = Vec2::new(-b.center().x, y - b.y0); + y += b.height() + 36.0; + (Placed { spread: SpreadRef::Doc(i), offset }, b + offset) + }) + .collect() +} + +/// View transform showing canvas point `c` at the centre of the image at `scale` px/pt. +fn view_at(c: designcraft_geom::Point, scale: f64) -> Affine { + Affine::translate((W as f64 / 2.0, H as f64 / 2.0)) * Affine::scale(scale) * Affine::translate(-c.to_vec2()) +} + +struct Row { + name: String, + ms: f64, + budget: Option, +} + +fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option) { + let name = name.into(); + eprintln!(" … {name}: {ms:.2} ms"); + rows.push(Row { name, ms, budget }); +} + +/// Measure `doc`. `runs` = repetitions per row (median). +fn measure(doc: &Document, runs: usize) -> Vec { + let mut rows = vec![]; + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let chars: usize = doc.stories.values().map(|s| s.text.len()).sum(); + // Budget: 250 ms per 300k characters (MT), scaled to this document. + let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0); + let mut cache = Arc::new(Cache::new()); + row( + &mut rows, + format!("compose all stories cold, MT ({} stories, {}k chars)", doc.stories.len(), chars / 1000), + median_ms(runs.min(3), || cache = compose_all(doc, cores)), + Some(compose_budget), + ); + row(&mut rows, "compose all stories cold, 1 thread", median_ms(1, || drop(compose_all(doc, 1))), None); + + let placed = layout(doc); + let all: Vec = placed.iter().map(|p| p.0).collect(); + let mid = placed.len() / 2; + let centre = placed[mid].1.center(); + let greek = |scale: f64| RenderOptions { + background: Some([230, 230, 230, 255]), + page_shadow: true, + greek_below_px: 3.0 * HIDPI.min(scale.max(1.0)), + ..Default::default() + }; + let mut r = Renderer::new(); + // Warm-up: decode images, glyph outlines, compose (already cached). + let fit = { + let b = placed[mid].1; + let s = ((W as f64 - 80.0) / b.width()).min((H as f64 - 80.0) / b.height()); + (s, view_at(centre, s)) + }; + let dump = std::env::var("DESIGNCRAFT_PERF_DUMP").ok(); + let save = |name: &str, img: designcraft_render::Rendered| { + if let Some(d) = &dump { + let _ = std::fs::create_dir_all(d); + let _ = std::fs::write(format!("{d}/{name}.png"), img.to_png()); + } + }; + save("fit", r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0))); + row( + &mut rows, + format!("render spread, fit ({:.0}%), {W}×{H}", fit.0 / HIDPI * 100.0), + median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)))), + Some(16.0), + ); + for zoom in [0.25, 1.0, 4.0] { + let s = zoom * HIDPI; + // At 100% look at the text block of the left page. + let c = if zoom >= 1.0 { centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0) } else { centre }; + let v = view_at(c, s); + save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, W, H, v, &greek(s))); + let budget = if zoom == 1.0 { Some(16.0) } else { None }; + row( + &mut rows, + format!("render at {:.0}%, {W}×{H}", zoom * 100.0), + median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, v, &greek(s)))), + budget, + ); + } + // Panning at 100%: a new view every frame (no pixel cache), same glyph caches. + let mut dy = 0.0; + let pan = median_ms(runs, || { + dy += 37.0; + let v = view_at(centre + Vec2::new(-200.0, dy - 300.0), HIDPI); + drop(r.render(doc, &cache, &all, W, H, v, &greek(HIDPI))); + }); + row(&mut rows, "render at 100% while panning", pan, Some(16.0)); + // Cold renderer (no glyph-path / image caches): first frame after open. + let cold = median_ms(runs.min(3), || { + let mut r = Renderer::new(); + drop(r.render(doc, &cache, &all, W, H, view_at(centre, HIDPI), &greek(HIDPI))); + }); + row(&mut rows, "render at 100%, cold renderer", cold, None); + + // Keystroke: insert a character in the story of the spread's first frame, recompose, repaint. + if let Some(sid) = doc.spreads[mid].items.iter().find_map(|i| i.text_frame().map(|t| t.story)) { + let mut d = doc.clone(); + let mut k = 0usize; + let v = view_at(centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0), HIDPI); + let ms = median_ms(runs, || { + if let Some(st) = d.stories.get_mut(&sid) { + let st = Arc::make_mut(st); + let at = (st.text.len() / 2 + k).min(st.text.len()); + let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0); + st.text.insert(at, 'x'); + if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) { + run.len += 1; + } + st.rev += 1; + k += 1; + } + drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI))); + }); + row(&mut rows, "keystroke: recompose story + repaint at 100%", ms, Some(8.0)); + } + + let opts = RenderOptions { printing_only: true, ..Default::default() }; + let mut png = 0usize; + let ms = median_ms(runs.min(5), || { + let img = r.render_page(doc, &cache, doc.first_page_of_spread(mid), 150.0 / 72.0, true, &opts).expect("page"); + png = img.to_png().len(); + }); + row(&mut rows, format!("export page PNG at 150 dpi ({} kB)", png / 1024), ms, None); + + let mut bytes = vec![]; + row( + &mut rows, + format!("save .designcraft ({} pages)", doc.page_count()), + median_ms(runs.min(3), || bytes = designcraft_format::save(doc).expect("save")), + Some(300.0), + ); + row(&mut rows, "open .designcraft", median_ms(runs.min(3), || drop(designcraft_format::load(&bytes).expect("load"))), Some(300.0)); + let n = doc.page_count().min(100); + let pdf = designcraft_pdf::PdfOptions { pages: Some((0..n).collect()), ..Default::default() }; + row( + &mut rows, + format!("PDF export {n} pages"), + median_ms(1, || drop(designcraft_pdf::export_pdf(doc, &cache, &pdf).expect("pdf"))), + Some(2000.0 * n as f64 / 100.0), + ); + rows +} + +fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { + println!("{title}"); + let mut over = 0; + println!(" {:<60} {:>11} {:>10}", "", "measured", "budget"); + for r in rows { + let (b, flag) = match r.budget { + Some(b) if r.ms > b => { + over += 1; + (format!("{b:>7.0} ms"), "OVER") + } + Some(b) => (format!("{b:>7.0} ms"), "ok"), + None => (" — ".into(), ""), + }; + println!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms); + } + match (over, noisy) { + (0, _) => Ok(()), + (_, true) => { + println!("{over} over budget, but the machine is busy: re-run when idle."); + Ok(()) + } + _ => { + println!("{over} budget(s) exceeded"); + Ok(()) + } + } +} + +fn header() -> (usize, bool) { + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let load = load_average(); + let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); + println!( + "{cores} cores, load average {}, render threads {}", + load.map_or("?".into(), |l| format!("{l:.1}")), + designcraft_render::default_threads() + ); + if noisy { + println!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy."); + } + (cores, noisy) +} + +/// `designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict]`. +pub fn perf(args: &[String]) -> Result<(), String> { + let mut spec = Spec::default(); + let mut runs = 7usize; + let mut strict = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + let mut num = || it.next().and_then(|v| v.parse::().ok()).ok_or(format!("{a} needs a number")); + match a.as_str() { + "--pages" => spec.pages = num()?.max(1), + "--frames" => spec.frames_per_page = num()?.max(1), + "--chars" => spec.chars = num()?, + "--images" => spec.images = num()?, + "--runs" => runs = num()?.max(1), + "--strict" => strict = true, + other => return Err(format!("unknown perf option `{other}`")), + } + } + let (_, noisy) = header(); + let t = Instant::now(); + let doc = synthetic(&spec); + let frames = spec.pages * spec.frames_per_page; + println!( + "DesignCraft performance budgets — synthetic: {} pages, {frames} text frames in {} threaded stories, {}k chars, {} images ({} assets), {} gradients (built in {:.0} ms)", + spec.pages, + doc.stories.len(), + doc.stories.values().map(|s| s.text.len()).sum::() / 1000, + spec.images, + spec.assets, + spec.pages, + t.elapsed().as_secs_f64() * 1000.0 + ); + let rows = measure(&doc, runs); + print("", &rows, noisy)?; + let over = rows.iter().filter(|r| r.budget.is_some_and(|b| r.ms > b)).count(); + if strict && over > 0 && !noisy { Err(format!("{over} budget(s) exceeded")) } else { Ok(()) } +} + +/// `designcraft-cli bench FILE [--runs N]`: the same measurements on one document. +pub fn bench(args: &[String]) -> Result<(), String> { + let mut file = None; + let mut runs = 5usize; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--runs" => runs = it.next().and_then(|v| v.parse().ok()).ok_or("--runs needs a number")?, + f if file.is_none() && !f.starts_with("--") => file = Some(f.to_string()), + other => return Err(format!("unknown bench option `{other}`")), + } + } + let file = file.ok_or("usage: designcraft-cli bench FILE [--runs N]")?; + let (_, noisy) = header(); + let mut s = designcraft_engine::Session::new(); + let t = Instant::now(); + s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; + let open = t.elapsed().as_secs_f64() * 1000.0; + let doc = s.doc().map_err(|e| e.to_string())?.doc.clone(); + println!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len()); + let rows = measure(&doc, runs); + print("", &rows, noisy) +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 749cc8e3..0971fe8b 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -375,12 +375,67 @@ impl Default for DropShadow { } } +/// Inner shadow: a blurred, offset shadow inside the object's edge. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. + pub choke: f64, +} + +impl Default for InnerShadow { + fn default() -> Self { + InnerShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 135.0, distance: 7.0, size: 7.0, choke: 0.0 } + } +} + +/// Outer glow: a blurred silhouette around the object, without offset. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OuterGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. + pub spread: f64, +} + +impl Default for OuterGlow { + fn default() -> Self { + OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + } +} + +fn is_default(v: &T) -> bool { + *v == T::default() +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Effects { + /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows + /// the silhouette instead of blurring it. pub drop_shadow: DropShadow, /// Basic feather width (0 = off). pub feather: f64, + #[serde(skip_serializing_if = "is_default")] + pub inner_shadow: InnerShadow, + #[serde(skip_serializing_if = "is_default")] + pub outer_glow: OuterGlow, +} + +impl Effects { + /// Any effect drawn with soft (raster) filters? + pub fn any(&self) -> bool { + self.drop_shadow.on || self.feather > 0.0 || self.inner_shadow.on || self.outer_glow.on + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs new file mode 100644 index 00000000..2967cafe --- /dev/null +++ b/crates/render/src/fx.rs @@ -0,0 +1,204 @@ +//! Soft object effects (Effects panel): drop shadow, outer glow, inner shadow and basic feather, +//! drawn with vello filter layers. +//! +//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded +//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its +//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts +//! draw directly. +//! +//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, +//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), +//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's +//! opacity rides on the filter colour's alpha. +//! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top. +//! - Feather: the object is masked by its shape inset by half the width and blurred, so the +//! edge fades from ~0 at the path to full strength `width` inside. + +use designcraft_doc::Item; +use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; +use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; +use vello_cpu::{RenderContext, kurbo}; + +use crate::{Frame, Renderer, color_of}; + +/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance. +pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 { + let a = angle.to_radians(); + Vec2::new(-a.cos() * distance, a.sin() * distance) +} + +/// How far (points) the effects of `it` reach outside its geometry. +pub(crate) fn outset(it: &Item) -> f64 { + let e = &it.effects; + let mut o: f64 = 0.0; + if e.drop_shadow.on { + o = o.max(e.drop_shadow.distance.abs() + e.drop_shadow.size.abs() * 1.6 + 1.0); + } + if e.outer_glow.on { + o = o.max(e.outer_glow.size.abs() * 1.6 + 1.0); + } + o +} + +/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. +fn split(size: f64, pct: f64) -> (f64, f64) { + let size = size.max(0.0); + let p = (pct / 100.0).clamp(0.0, 1.0); + (size * (1.0 - p) / 2.0, size * p) +} + +fn blur(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None }) +} + +fn shadow(sigma: f64, color: peniko::Color) -> Filter { + Filter::from_primitive(FilterPrimitive::DropShadowOnly { + dx: 0.0, + dy: 0.0, + std_deviation: sigma.max(0.0) as f32, + color, + edge_mode: EdgeMode::None, + }) +} + +impl Renderer { + /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. + pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + let doc = f.doc; + let e = &it.effects; + let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.weight }; + // Spread-space reach of what the object paints. + let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); + // The silhouette is the shape when the object paints its area (fill / image); text frames + // without a fill cast the shadow of their text only, which can't be grown. + let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)); + let mut below = vec![]; + if e.outer_glow.on { + let g = &e.outer_glow; + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + } + if e.drop_shadow.on { + let d = &e.drop_shadow; + below.push((offset(d.angle, d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + } + for (off, size, pct, color, opacity) in below { + let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; + let (sigma, grow) = split(size, pct); + let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); + // The offset moves the geometry, not the filter: vello drops layer content that lies + // entirely outside the viewport before filtering. + let r = (reach + off).inflate(grow, grow); + self.with_filters(ctx, f, r, sigma, |me, c, fr| { + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + c.set_transform(moved.view); + c.push_layer(None, None, None, None, Some(filter)); + me.draw_body(c, &moved, it, bp, xf, page_name); + if grow > 0.0 && solid { + c.set_transform(moved.view * xf); + c.set_paint(peniko::Color::BLACK); + c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + } + c.pop_layer(); + }); + } + // The object, feathered or not. + if e.feather > 0.0 && it.path.is_closed() { + let w = e.feather; + self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + me.draw_body(c, fr, it, bp, xf, page_name); + // Keep the body where the blurred inset silhouette is. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(w / 4.0))); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.fill_path(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + c.set_transform(fr.view * xf); + c.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } else { + self.draw_body(ctx, f, it, bp, xf, page_name); + } + // Inner shadow, clipped to the shape. + if e.inner_shadow.on + && it.path.is_closed() + && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) + { + let s = &e.inner_shadow; + let (sigma, choke) = split(s.size, s.choke); + let off = offset(s.angle, s.distance); + let paint = color_of(&c, s.opacity.clamp(0.0, 1.0)); + self.with_filters(ctx, f, reach, sigma, |_, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + // Everything outside the offset shape, so the shadow bleeds in from the edges. + let m = Affine::translate(off) * xf; + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); + let mut shape = bp.clone(); + shape.apply_affine(m); + inv.extend(shape.iter()); + c.set_paint(paint); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + c.set_fill_rule(peniko::Fill::NonZero); + if choke > 0.0 { + c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(&shape); + } + c.pop_layer(); + c.pop_layer(); + }); + } + } + + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a + /// multithreaded context the layer is drawn into an offscreen single-threaded context + /// covering `reach` (spread space) plus the blur's spread, then composited back. + pub(crate) fn with_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + sigma: f64, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) { + if !f.mt { + return draw(self, ctx, f); + } + // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. + let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; + let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); + let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64)); + if w < 1.0 || h < 1.0 { + return; + } + let (w, h) = (w as u16, h as u16); + let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + draw(self, &mut off, &shifted); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + ctx.set_transform(Affine::translate((x0, y0))); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + ctx.set_transform(Affine::IDENTITY); + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 00000000..89760710 --- /dev/null +++ b/crates/render/src/images.rs @@ -0,0 +1,134 @@ +//! Decoded placed images, shared by every [`crate::Renderer`] in the process (canvas worker, +//! thumbnails, exports), so each asset is decoded once and each mip level built once. +//! +//! Entries are keyed by the address of the asset's encoded bytes (`Arc>`) and hold a +//! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels +//! are evicted beyond a memory budget. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use vello_cpu::Pixmap; + +use crate::{decode_pixmap, halve}; + +/// Decoded pixels kept (bytes). +const BUDGET: usize = 768 << 20; + +struct Entry { + _bytes: Arc>, + pm: Arc, + stamp: u64, +} + +#[derive(Default)] +struct Cache { + map: HashMap<(usize, u8), Entry>, + bytes: usize, + clock: u64, + /// Assets that failed to decode (don't retry every frame). + bad: HashMap>>, +} + +static CACHE: Mutex> = Mutex::new(None); + +fn size_of(pm: &Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + +fn with(f: impl FnOnce(&mut Cache) -> R) -> R { + let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + f(g.get_or_insert_with(Cache::default)) +} + +fn lookup(key: (usize, u8)) -> Option> { + with(|c| { + c.clock += 1; + let clock = c.clock; + c.map.get_mut(&key).map(|e| { + e.stamp = clock; + e.pm.clone() + }) + }) +} + +fn store(bytes: &Arc>, level: u8, pm: Arc) { + with(|c| { + c.clock += 1; + let key = (Arc::as_ptr(bytes) as usize, level); + let n = size_of(&pm); + if let Some(old) = c.map.insert(key, Entry { _bytes: bytes.clone(), pm, stamp: c.clock }) { + c.bytes -= size_of(&old.pm); + } + c.bytes += n; + if c.bytes > BUDGET { + let mut v: Vec<(u64, (usize, u8))> = c.map.iter().map(|(k, e)| (e.stamp, *k)).collect(); + v.sort_unstable(); + for (_, k) in v { + if c.bytes <= BUDGET * 3 / 4 { + break; + } + if k == key { + continue; + } + if let Some(e) = c.map.remove(&k) { + c.bytes -= size_of(&e.pm); + } + } + } + }); +} + +/// Full-resolution decode of `bytes` (cached). +pub fn decoded(bytes: &Arc>) -> Option> { + let id = Arc::as_ptr(bytes) as usize; + if let Some(pm) = lookup((id, 0)) { + return Some(pm); + } + if with(|c| c.bad.contains_key(&id)) { + return None; + } + // Decode outside the lock: other threads keep drawing cached images meanwhile. + match decode_pixmap(bytes) { + Some(pm) => { + let pm = Arc::new(pm); + store(bytes, 0, pm.clone()); + Some(pm) + } + None => { + with(|c| c.bad.insert(id, bytes.clone())); + None + } + } +} + +/// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic +/// `width_pt` points wide. +pub fn mip(bytes: &Arc>, width_pt: f64, on_screen: f64) -> Option> { + let full = decoded(bytes)?; + let ppt = full.width() as f64 / width_pt.max(1e-6); + let id = Arc::as_ptr(bytes) as usize; + let mut pm = full; + let mut level = 0u8; + while ppt / (1u64 << (level + 1)) as f64 >= on_screen * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { + level += 1; + pm = match lookup((id, level)) { + Some(p) => p, + None => { + let half = Arc::new(halve(&pm)); + store(bytes, level, half.clone()); + half + } + }; + } + Some(pm) +} + +/// Drop every decoded image. +pub fn clear() { + with(|c| { + c.map.clear(); + c.bad.clear(); + c.bytes = 0; + }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7dacf57b..96df7323 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,9 +12,8 @@ use std::collections::HashMap; use std::sync::Arc; use designcraft_color::BlendMode as DcBlend; -use designcraft_compose::{Cache, ComposedStory, FrameText}; -use designcraft_doc::{AssetId, Content, Document, Item, PageSide, SpreadRef, StrokeAlign, StrokeType}; -use designcraft_fonts::FontDb; +use designcraft_compose::{Cache, ComposedStory}; +use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef, StoryId, StrokeAlign, StrokeType}; use designcraft_geom::corners; use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use vello_cpu::kurbo; @@ -23,6 +22,10 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; pub use vello_cpu; +mod fx; +pub mod images; +mod text; + /// A rendered image (premultiplied RGBA8, row-major). #[derive(Clone)] pub struct Rendered { @@ -115,11 +118,14 @@ pub struct FrameStats { pub micros: u64, } -/// Reusable renderer (keeps the render context and decoded images). +/// Reusable renderer: keeps the render context, the frame-space glyph paths of composed text +/// and (process-wide) decoded images between renders. pub struct Renderer { ctx: Option, resources: Resources, - images: HashMap<(AssetId, usize), Arc>, + glyphs: text::GlyphCache, + /// Composed stories looked up during the current render. + stories: HashMap<(StoryId, Option), Arc>, pub threads: u16, pub stats: FrameStats, } @@ -130,13 +136,18 @@ impl Default for Renderer { } } -struct Frame<'a> { +#[derive(Clone, Copy)] +pub(crate) struct Frame<'a> { doc: &'a Document, cache: &'a Cache, view: Affine, + /// Visible area in spread space. visible: Rect, + /// Size of a device pixel in points. px: f64, opts: &'a RenderOptions, + /// Drawing into a multithreaded context (no filter layers). + mt: bool, } pub fn default_threads() -> u16 { @@ -155,7 +166,25 @@ pub fn default_threads() -> u16 { impl Renderer { pub fn new() -> Self { - Self { ctx: None, resources: Resources::new(), images: HashMap::new(), threads: default_threads(), stats: FrameStats::default() } + Self { + ctx: None, + resources: Resources::new(), + glyphs: text::GlyphCache::default(), + stories: HashMap::new(), + threads: default_threads(), + stats: FrameStats::default(), + } + } + + /// Drop cached glyph paths (they are rebuilt on demand). + pub fn clear_caches(&mut self) { + self.glyphs.clear(); + self.stories.clear(); + } + + /// Number of text frames whose glyph paths are cached. + pub fn cached_frames(&self) -> usize { + self.glyphs.len() } /// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels). @@ -173,13 +202,16 @@ impl Renderer { let w = width.clamp(1, u16::MAX as u32) as u16; let h = height.clamp(1, u16::MAX as u32) as u16; let mut ctx = match self.ctx.take() { - Some(mut c) if c.width() == w && c.height() == h => { + Some(mut c) if c.width() == w && c.height() == h && c.render_settings().num_threads == self.threads => { c.reset(); c } _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }), }; + let mt = ctx.render_settings().num_threads > 0; self.stats = FrameStats::default(); + self.stories.clear(); + self.glyphs.tick(); if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); @@ -196,15 +228,17 @@ impl Renderer { if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) { continue; } - let f = Frame { doc, cache, view: sview, visible, px, opts }; + let f = Frame { doc, cache, view: sview, visible, px, opts, mt }; self.draw_spread(&mut ctx, &f, pl.spread); } ctx.flush(); - let mut pm = Pixmap::new(w, h); - ctx.render(&mut pm, &mut self.resources); + // Render straight into the returned buffer (no extra copy of the frame). + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut self.resources); self.ctx = Some(ctx); + self.stories.clear(); self.stats.micros = now().saturating_sub(start); - Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() } + Rendered { width: w as u32, height: h as u32, pixels } } /// Render one document page (absolute index) at `scale` px/pt, optionally including bleed. @@ -308,33 +342,53 @@ impl Renderer { } return; } - if !rect_overlaps(vb, f.visible) && !matches!(it.content, Content::Text(_)) { + // Composed text stays inside its frame apart from glyph overhang (italics, swashes, big + // initials), so text frames get a margin rather than being exempt from culling. + let margin = match it.content { + Content::Text(_) => 36.0 + 0.25 * vb.width().max(vb.height()), + _ => 0.0, + }; + let fx_outset = if it.effects.any() { fx::outset(it) } else { 0.0 }; + if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) { return; } self.stats.items += 1; - let doc = f.doc; let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; if layered { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } - // Drop shadow (simple offset silhouette). - if it.effects.drop_shadow.on { - let ds = &it.effects.drop_shadow; - let a = ds.angle.to_radians(); - let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance); - if let Some(c) = doc.resolve_color(&ds.color, 1.0) { - ctx.set_transform(f.view * Affine::translate(off) * xf); - ctx.set_paint(color_of(&c, ds.opacity)); - ctx.fill_path(&bp); - } + if it.effects.any() { + self.draw_item_fx(ctx, f, it, &bp, xf, page_name); + } else { + self.draw_body(ctx, f, it, &bp, xf, page_name); } + if layered { + ctx.pop_layer(); + } + } + + /// The composed story for a frame (memoised for the current render: the cache lookup + /// validates the whole thread each time). + fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc { + let key = (sid, page_name.map(str::to_string)); + if let Some(cs) = self.stories.get(&key) { + return cs.clone(); + } + let cs = f.cache.get(f.doc, sid, page_name); + self.stories.insert(key, cs.clone()); + cs + } + + /// Fill, content and stroke of a leaf item (no opacity, blend or effects). + pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + let doc = f.doc; // Fill. if !it.fill.is_none() { ctx.set_transform(f.view * xf); if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() { - ctx.fill_path(&bp); + ctx.fill_path(bp); } ctx.reset_paint_transform(); } @@ -342,12 +396,12 @@ impl Renderer { match &it.content { Content::Graphic(g) => { ctx.set_transform(f.view * xf); - ctx.push_clip_layer(&bp); + ctx.push_clip_layer(bp); self.draw_graphic(ctx, f, g, xf); ctx.pop_layer(); } Content::Text(tf) => { - let cs = f.cache.get(doc, tf.story, page_name); + let cs = self.story(f, tf.story, page_name); if let Some(ft) = cs.frame(it.id) { self.draw_text(ctx, f, &cs, ft, xf); } @@ -356,10 +410,7 @@ impl Renderer { } // Stroke. if !it.stroke.is_none() { - self.draw_stroke(ctx, f, it, &bp, xf); - } - if layered { - ctx.pop_layer(); + self.draw_stroke(ctx, f, it, bp, xf); } } @@ -417,30 +468,7 @@ impl Renderer { let Some(asset) = f.doc.assets.get(&g.asset) else { return }; // Pick a mip level close to the on-screen size. let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); - let full = match self.images.get(&(g.asset, 0)) { - Some(p) => p.clone(), - None => { - let Some(pm) = decode_pixmap(&asset.data) else { return }; - let pm = Arc::new(pm); - self.images.insert((g.asset, 0), pm.clone()); - pm - } - }; - let ppt = full.width() as f64 / g.size.0.max(1e-6); // pixels per point - let need = on_screen * 1.0; // pixels per point on screen - let mut level = 0usize; - let mut pm = full.clone(); - while ppt / (1 << (level + 1)) as f64 >= need * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { - level += 1; - pm = match self.images.get(&(g.asset, level)) { - Some(p) => p.clone(), - None => { - let half = Arc::new(halve(&pm)); - self.images.insert((g.asset, level), half.clone()); - half - } - }; - } + let Some(pm) = images::mip(&asset.data, g.size.0, on_screen) else { return }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); let sx = g.size.0 / pm.width().max(1) as f64; @@ -453,97 +481,6 @@ impl Renderer { ctx.fill_rect(&rect); ctx.reset_paint_transform(); } - - fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, xf: Affine) { - let db = FontDb::global(); - let doc = f.doc; - // Decorations under text (shading) and rules. - for d in &ft.decos { - if let Some(c) = doc.resolve_color(&d.color, d.tint) { - ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_rect(&d.rect); - } - } - let scale = (f.view * xf).determinant().abs().sqrt(); - // Batch glyph outlines per run style into one path (in frame inner space). - let mut batches: HashMap = HashMap::new(); - let mut lines_deco: Vec<(u32, kurbo::Rect)> = Vec::new(); - for l in &ft.lines { - let greek = f.opts.greek_below_px > 0.0 && l.ascent * scale < f.opts.greek_below_px; - if greek { - if let (Some(a), Some(_)) = (l.glyphs.first(), l.glyphs.last()) { - let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline); - lines_deco.push((u32::MAX, r)); - } - continue; - } - for g in &l.glyphs { - if !g.visible { - continue; - } - let style = &cs.styles[g.style as usize]; - if style.underline || style.strikethrough { - let w = style.size / 14.0; - if style.underline { - lines_deco - .push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); - } - if style.strikethrough { - lines_deco - .push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); - } - } - let outline = db.outline(&g.face, g.gid); - if outline.elements().is_empty() { - continue; - } - let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let b = batches.entry(g.style).or_default(); - for el in outline.elements() { - b.push(transform_el(a, *el)); - } - self.stats.glyphs += 1; - } - } - ctx.set_transform(f.view * xf); - for (si, bp) in &batches { - let st = &cs.styles[*si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_path(bp); - } - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - } - } - for (si, r) in lines_deco { - let c = if si == u32::MAX { - designcraft_color::Color::gray(0.35) - } else { - let st = &cs.styles[si as usize]; - doc.resolve_color(&st.fill, st.fill_tint).unwrap_or(designcraft_color::Color::BLACK) - }; - ctx.set_paint(color_of(&c, if si == u32::MAX { 0.5 } else { 1.0 })); - ctx.fill_rect(&r); - } - } -} - -fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl { - use kurbo::PathEl::*; - match el { - MoveTo(p) => MoveTo(a * p), - LineTo(p) => LineTo(a * p), - QuadTo(p1, p2) => QuadTo(a * p1, a * p2), - CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3), - ClosePath => ClosePath, - } } fn rect_overlaps(a: Rect, b: Rect) -> bool { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs new file mode 100644 index 00000000..5eccc8cf --- /dev/null +++ b/crates/render/src/text.rs @@ -0,0 +1,264 @@ +//! Composed text → pixels. +//! +//! Glyph outlines of a frame are transformed into frame space and batched per line and run style +//! once, then cached per (composed story allocation, frame id): a composed story is immutable, so +//! the cache entry stays valid for as long as the composition cache hands out the same `Arc`. +//! Each render only fills the lines that intersect the visible rectangle, and lines smaller than +//! the greeking threshold become grey bars without touching any outline. + +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_compose::{ComposedStory, FrameText, Line}; +use designcraft_doc::ItemId; +use designcraft_fonts::FontDb; +use designcraft_geom::{Affine, BezPath, Rect, Shape}; +use vello_cpu::RenderContext; +use vello_cpu::kurbo; +use vello_cpu::peniko; + +use crate::{Frame, Renderer, color_of, rect_overlaps}; + +/// Cached outlines of one composed line. +pub(crate) struct LineGlyphs { + /// Frame-space bounds of everything drawn for the line (empty lines: `None`). + pub bounds: Option, + /// Glyph outlines batched per run style. + pub runs: Vec<(u32, BezPath)>, + /// Underline / strikethrough bars. + pub decos: Vec<(u32, Rect)>, + pub glyphs: usize, +} + +pub(crate) struct FrameGlyphs { + pub lines: Vec, + /// Path elements held (cache budget). + pub elements: usize, +} + +struct Entry { + /// Keeps the composed story alive so its address can't be reused while cached. + _story: Arc, + glyphs: Arc, + stamp: u64, +} + +/// Path cache keyed by (composed story address, frame id). +#[derive(Default)] +pub(crate) struct GlyphCache { + map: HashMap<(usize, ItemId), Entry>, + elements: usize, + clock: u64, +} + +/// Roughly 56 bytes per element: ~110 MB of cached outlines. +const MAX_ELEMENTS: usize = 2_000_000; + +impl GlyphCache { + pub fn tick(&mut self) { + self.clock += 1; + } + + fn get(&mut self, cs: &Arc, ft: &FrameText) -> Arc { + let key = (Arc::as_ptr(cs) as usize, ft.frame); + let stamp = self.clock; + if let Some(e) = self.map.get_mut(&key) { + e.stamp = stamp; + return e.glyphs.clone(); + } + let g = Arc::new(build(cs, ft)); + self.elements += g.elements; + self.map.insert(key, Entry { _story: cs.clone(), glyphs: g.clone(), stamp }); + if self.elements > MAX_ELEMENTS { + self.evict(); + } + g + } + + /// Drop least recently used entries (never the ones used by the current render) until the + /// cache is at half its budget. + fn evict(&mut self) { + let mut stamps: Vec<(u64, usize, (usize, ItemId))> = self.map.iter().map(|(k, e)| (e.stamp, e.glyphs.elements, *k)).collect(); + stamps.sort_unstable_by_key(|s| s.0); + for (stamp, n, k) in stamps { + if self.elements <= MAX_ELEMENTS / 2 || stamp >= self.clock { + break; + } + self.map.remove(&k); + self.elements -= n; + } + } + + pub fn clear(&mut self) { + self.map.clear(); + self.elements = 0; + } + + pub fn len(&self) -> usize { + self.map.len() + } +} + +fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl { + use kurbo::PathEl::*; + match el { + MoveTo(p) => MoveTo(a * p), + LineTo(p) => LineTo(a * p), + QuadTo(p1, p2) => QuadTo(a * p1, a * p2), + CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3), + ClosePath => ClosePath, + } +} + +fn union(r: Option, b: Rect) -> Option { + Some(r.map_or(b, |r| r.union(b))) +} + +fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line) -> LineGlyphs { + let mut runs: Vec<(u32, BezPath)> = Vec::new(); + let mut decos = Vec::new(); + let mut glyphs = 0; + for g in &l.glyphs { + if !g.visible { + continue; + } + let style = &cs.styles[g.style as usize]; + if style.underline || style.strikethrough { + let w = style.size / 14.0; + if style.underline { + decos.push((g.style, Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); + } + if style.strikethrough { + decos.push((g.style, Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); + } + } + let outline = db.outline(&g.face, g.gid); + if outline.elements().is_empty() { + continue; + } + let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); + let bp = match runs.iter_mut().find(|r| r.0 == g.style) { + Some(r) => &mut r.1, + None => { + runs.push((g.style, BezPath::new())); + &mut runs.last_mut().expect("pushed").1 + } + }; + for el in outline.elements() { + bp.push(transform_el(a, *el)); + } + glyphs += 1; + } + let mut bounds = None; + for (si, bp) in &runs { + let st = &cs.styles[*si as usize]; + let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; + bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); + } + for (_, r) in &decos { + bounds = union(bounds, *r); + } + LineGlyphs { bounds, runs, decos, glyphs } +} + +fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs { + let db = FontDb::global(); + let lines: Vec = ft.lines.iter().map(|l| build_line(db, cs, l)).collect(); + let elements = lines.iter().flat_map(|l| &l.runs).map(|(_, bp)| bp.elements().len()).sum(); + FrameGlyphs { lines, elements } +} + +/// Conservative frame-space box of a line from its metrics (no outlines needed). +fn line_box(l: &Line) -> Option { + let first = l.glyphs.first()?; + let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); + let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); + let pad = l.ascent.max(1.0); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad)) +} + +impl Renderer { + pub(crate) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &Arc, ft: &FrameText, xf: Affine) { + let doc = f.doc; + let m = f.view * xf; + // Visible area in frame space. + if xf.determinant().abs() < 1e-12 { + return; + } + let vis = xf.inverse().transform_rect_bbox(f.visible); + // Decorations under text (shading) and rules. + for d in &ft.decos { + if !rect_overlaps(d.rect, vis) { + continue; + } + if let Some(c) = doc.resolve_color(&d.color, d.tint) { + ctx.set_transform(m); + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(&d.rect); + } + } + let scale = m.determinant().abs().sqrt(); + let greek_px = f.opts.greek_below_px; + let mut greek = BezPath::new(); + let mut shown: Vec = Vec::new(); + for (i, l) in ft.lines.iter().enumerate() { + let Some(b) = line_box(l) else { continue }; + if !rect_overlaps(b, vis) { + continue; + } + if greek_px > 0.0 && l.ascent * scale < greek_px { + if let Some(a) = l.glyphs.first() { + let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline); + if r.width() > 0.0 { + greek.extend(r.path_elements(0.1)); + } + } + continue; + } + shown.push(i); + } + ctx.set_transform(m); + if !shown.is_empty() { + let fg = self.glyphs.get(cs, ft); + // Resolve each style's paints once per frame. + let mut fills: Vec>> = vec![None; cs.styles.len()]; + let mut fill_of = |si: u32| -> Option { + *fills[si as usize].get_or_insert_with(|| { + let st = &cs.styles[si as usize]; + doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) + }) + }; + for i in shown { + let lg = &fg.lines[i]; + let Some(b) = lg.bounds else { continue }; + if !rect_overlaps(b, vis) { + continue; + } + self.stats.glyphs += lg.glyphs; + for (si, bp) in &lg.runs { + if let Some(c) = fill_of(*si) { + ctx.set_paint(c); + ctx.fill_path(bp); + } + let st = &cs.styles[*si as usize]; + if st.stroke != designcraft_color::swatch::NONE + && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + { + ctx.set_paint(color_of(&c, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); + ctx.stroke_path(bp); + } + } + for (si, r) in &lg.decos { + ctx.set_paint(fill_of(*si).unwrap_or(peniko::Color::BLACK)); + ctx.fill_rect(r); + } + } + } + if !greek.elements().is_empty() { + ctx.set_paint(color_of(&designcraft_color::Color::gray(0.35), 0.5)); + ctx.fill_path(&greek); + } + } +} diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs new file mode 100644 index 00000000..f72e2f77 --- /dev/null +++ b/crates/render/tests/effects.rs @@ -0,0 +1,224 @@ +//! Golden images for the soft object effects (drop shadow, outer glow, inner shadow, feather), +//! plus property checks and a multithreaded == single-threaded check. +//! +//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur +//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended +//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`. + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +const SIZE: u32 = 160; + +fn doc() -> Document { + Document::new(&NewDocument { width: 160.0, height: 160.0, facing_pages: false, ..Default::default() }) +} + +fn rect_item(d: &mut Document, r: Rect, swatch: &str) -> ItemId { + let id = ItemId(d.alloc()); + let lid = d.default_layer(); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.fill = Fill::swatch(swatch); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id +} + +fn render_with(d: &Document, threads: u16) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), offset: Vec2::ZERO }], SIZE, SIZE, Affine::IDENTITY, &opts) +} + +fn render(d: &Document) -> Rendered { + render_with(d, 0) +} + +fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 +} + +const WHITE: u32 = 765; + +/// Tolerant comparison with `tests/golden/{name}.png` (written when missing or when blessing). +fn golden(name: &str, img: &Rendered) { + let path = format!("{}/tests/golden/{name}.png", env!("CARGO_MANIFEST_DIR")); + let bless = std::env::var_os("DESIGNCRAFT_BLESS").is_some(); + let Ok(bytes) = std::fs::read(&path).map_err(|_| ()).and_then(|b| if bless { Err(()) } else { Ok(b) }) else { + std::fs::create_dir_all(format!("{}/tests/golden", env!("CARGO_MANIFEST_DIR"))).unwrap(); + std::fs::write(&path, img.to_png()).unwrap(); + eprintln!("wrote golden {path}"); + return; + }; + let want = image::load_from_memory(&bytes).unwrap().to_rgba8(); + assert_eq!((want.width(), want.height()), (img.width, img.height), "{name}: size"); + let got = img.to_straight(); + let (mut bad, mut sum, mut worst) = (0usize, 0u64, 0u8); + for (a, b) in got.chunks_exact(4).zip(want.as_raw().chunks_exact(4)) { + let d = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + sum += d as u64; + worst = worst.max(d); + if d > 8 { + bad += 1; + } + } + let n = (img.width * img.height) as usize; + let mean = sum as f64 / n as f64; + if bad * 200 > n || mean > 1.0 { + let out = std::env::temp_dir().join(format!("designcraft-golden-{name}.png")); + let _ = std::fs::write(&out, img.to_png()); + panic!("{name}: {bad} pixels differ by > 8 (worst {worst}, mean {mean:.2}); got {}", out.display()); + } +} + +fn shadow_doc(spread: f64) -> Document { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0"); + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.distance = 12.0; + ds.angle = 135.0; + ds.size = 10.0; + ds.spread = spread; + ds.opacity = 0.8; + d +} + +#[test] +fn drop_shadow_is_soft_offset_and_under_the_object() { + let img = render(&shadow_doc(0.0)); + golden("drop-shadow", &img); + // The object is unchanged on top. + let c = img.pixel(60, 60); + assert!(c[0] < 60 && c[2] > 200, "object on top: {c:?}"); + // Below-right is shadowed, above-left is not. + assert!(lum(img.pixel(104, 70)) < 500, "shadow below-right: {:?}", img.pixel(104, 70)); + assert_eq!(lum(img.pixel(22, 22)), WHITE); + // Soft: the shadow fades out gradually past its hard edge (100 + 8.5 offset). + let row: Vec = (104..130).map(|x| lum(img.pixel(x, 80))).collect(); + let mid = row.iter().filter(|&&v| v > 60 && v < 700).count(); + assert!(mid >= 6, "gradual falloff: {row:?}"); + assert!(row.windows(2).all(|w| w[1] + 2 >= w[0]), "monotonic: {row:?}"); +} + +#[test] +fn drop_shadow_spread_hardens_and_grows() { + let soft = render(&shadow_doc(0.0)); + let hard = render(&shadow_doc(100.0)); + golden("drop-shadow-spread", &hard); + // Full spread: no blur, the silhouette grows by `size` → solid well past the offset edge. + let x = 100 + 8 + 6; + assert!(lum(hard.pixel(x, 80)) < lum(soft.pixel(x, 80)), "spread grows the shadow"); + assert_eq!(lum(hard.pixel(126, 80)), WHITE, "hard edge"); +} + +#[test] +fn drop_shadow_of_text_follows_the_glyphs() { + let mut d = doc(); + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 150.0, 150.0), lid, "Shadow", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..6, |f| f.over.size = Some(40.0)); + let ds = &mut d.item_mut(fid).unwrap().effects.drop_shadow; + ds.on = true; + ds.distance = 4.0; + ds.size = 3.0; + let img = render(&d); + golden("drop-shadow-text", &img); + // No frame-shaped shadow: the bottom-right of the (unfilled) frame is clear. + assert_eq!(lum(img.pixel(140, 140)), WHITE); +} + +#[test] +fn outer_glow_surrounds_the_object() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=100 Y=0 K=0".into(); + g.opacity = 1.0; + let img = render(&d); + golden("outer-glow", &img); + for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { + let p = img.pixel(x, y); + assert!(p[1] < 200 && p[0] > p[1] + 40, "magenta glow at {x},{y}: {p:?}"); + } + assert_eq!(lum(img.pixel(10, 10)), WHITE); +} + +#[test] +fn inner_shadow_darkens_inside_the_lit_edge() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=0 M=0 Y=100 K=0"); + let s = &mut d.item_mut(id).unwrap().effects.inner_shadow; + s.on = true; + s.distance = 8.0; + s.size = 8.0; + s.opacity = 1.0; + let img = render(&d); + golden("inner-shadow", &img); + // Light from the top-left: the shadow sits inside the top-left edges. + assert!(lum(img.pixel(33, 80)) + 100 < lum(img.pixel(126, 80)), "{:?} vs {:?}", img.pixel(33, 80), img.pixel(126, 80)); + assert!(lum(img.pixel(80, 33)) + 100 < lum(img.pixel(80, 126))); + // Nothing outside the shape. + assert_eq!(lum(img.pixel(26, 80)), WHITE); +} + +#[test] +fn feather_fades_the_edge_inward() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Black]"); + d.item_mut(id).unwrap().effects.feather = 16.0; + let img = render(&d); + golden("feather", &img); + d.item_mut(id).unwrap().effects.feather = 0.0; + let solid = lum(render(&d).pixel(80, 80)); + let edge = lum(img.pixel(30, 80)); + let mid = lum(img.pixel(38, 80)); + let inside = lum(img.pixel(80, 80)); + assert!(edge > 600, "nearly transparent at the path: {edge}"); + assert!(mid > solid + 60 && mid < 650, "half way: {mid}"); + assert!(inside <= solid + 6, "solid inside: {inside} vs {solid}"); + assert_eq!(lum(img.pixel(27, 80)), WHITE, "nothing outside"); +} + +#[test] +fn shadow_of_an_object_outside_the_view_still_reaches_it() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(-60.0, 40.0, -2.0, 120.0), "[Paper]"); + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.angle = 180.0; + ds.distance = 20.0; + ds.size = 2.0; + ds.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80)); +} + +/// Multithreaded contexts draw effects offscreen; the result must match single-threaded. +#[test] +fn multithreaded_effects_match_single_threaded() { + let mut docs = vec![shadow_doc(0.0), shadow_doc(40.0)]; + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=0 M=30 Y=60 K=0"); + let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=100 M=0 Y=0 K=0"); + { + let it = d.item_mut(id).unwrap(); + it.effects.feather = 10.0; + it.effects.inner_shadow.on = true; + it.effects.outer_glow.on = true; + it.effects.drop_shadow.on = true; + it.opacity = 0.8; + } + docs.push(d); + for (i, d) in docs.iter().enumerate() { + let a = render_with(d, 0); + let b = render_with(d, 3); + let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + assert!(worst <= 4, "case {i}: max channel difference {worst}"); + } +} diff --git a/crates/render/tests/golden/drop-shadow-spread.png b/crates/render/tests/golden/drop-shadow-spread.png new file mode 100644 index 0000000000000000000000000000000000000000..48c7743865865a4eb45bfe558780fdc5efcac843 GIT binary patch literal 1461 zcmeAS@N?(olHy`uVBq!ia0vp^3qY8I4M=vMPuFE&V6E|VaSW+oe0zX-flB1GV;}#Q zzw145W_gi!^19sH+n^}3XWlfg_4AC3mEQd<+n!amtlc{M>iSz|;jv%uH$Pal`rmJX zn9{5D@=dGP{d(_Qa5eOQEyucDulDmF3=RMLU2(^&RsZc+K#JSxsQ799jN<3#K7Ltl zKiB&Gp2?ZBcD%B@8nyH1ve}gvn&pqYyuAEpeBY`64D9Uee;)AvQK?EI`ZDnx%X$TU90;~+-cj{dgi|mJ5 z&Fz}! zKjWJo?7y+I__>9J#e-G$egOkLn|KEuX+^72^)=pjh z@AK8DpYaW+>Nn2L-!&7+y<76x7Z`z0_upui`=hY#*ZJ=c*zIS4<9M!J?X0(NbAe*T zpPqR3$yy)#&&2YdH}2chxM@J0d+waxEA!u5;E#F5^YXfMzd05ML;rvDn{Rj4de39) z5BpgT);9yw@xAYV?@eF*FWK|a-~0dnM(T)xli!o2@xPWC$$2l(zZZ8e#sr56zZkZzTSoR=oWTSfo@B!XkxgX$D*je&~0sSn$Ou@5?@5am?W9 L>gTe~DWM4f-ce2w literal 0 HcmV?d00001 diff --git a/crates/render/tests/golden/drop-shadow-text.png b/crates/render/tests/golden/drop-shadow-text.png new file mode 100644 index 0000000000000000000000000000000000000000..2c9e62575d8e7ce1430bc916dfe7c9b3d3a2bce0 GIT binary patch literal 10988 zcmeHtdpy+n`}TL2thGCC>j*!epw|jT`1ekm_g)O<`vN=L!~Y`5O;yN0(Y=sT$CHL~?lm-L z2cPuJ@n;qN^PkV%%#;(knZ_nMoW*O!SHhp)w($Lw``PrI%N#qV|39xJ!u8G}pD5Zl zUga!tj|P7FSsJ+_=gn89a^=tG#DeW5U;iVuRzM%V{eR=b5~chWvv^G{Ee{P=SDmR; zz3VcCLhIO4lz-3Pk1yTuP%5|2xuil{{9c=<#FQ$Dc-qqa31Q8i3;)TLe2>vtW@oFA zxWL|J5;l`WYu1(wXwTfHOy3suvc+4q`T5$_tkM=1X1$870w!^Ra_b#s8>@7;uH1Aa zTM#MgAV@Y!rxmTfCOdZZ^muuD-@bQmEj*Km0IQl- z7BxFa*R-|0A5aNpM`B$%^9u^9=jTmT4(s2Pdf)fw6O2x);Y3Gq&BYX9F_A=SSY5zi zG_uNiS-f@Jg>{pYlV^N)>XZmMZyDlw7GtW2weXTA($Ad#D_d^b!*JdMVpG1BznI>h z?LeF=>p&Yrp)0wzdIjrI6LMwN{e#_eE-d-0@Dc{~c z<52Br-LCosZEqq`jdgrbs`9;5ktQ^!HPIyFv}MzdhI5V)GuaWw)kc93!}sr1AR?)x z#?=0N!};^`f_ao$YbuP^^n`S}KuiNg3qhu)rVXbuFJ2A~475px2R1pamewdWEeeae z71ZC~A6@HFX|&F3SJ5IR_8s#BPE?JXe#KmRh0(OQb~j<6A)&wO=IY6*seC7T-f7$Q zDi~*C%R$S9RigcDCU>>~$1mvNsw6}xG>s&}jwg(~Pjp;OrBeO;{of0$R4S{rd4ZCC zWeFzwOrWGH%*k|=Zw_xaj}hU2BH2WylYvX8ipudLs;HiH;Kr84-F`pPzq3Psx?dwT zQIM`GV$5wW4raDz+Y{N5f_!pNYS5Mo&FblgLVownElbe|D7lVasZrypqfrn>pU3C5 z_FmYRdaC_5-8VVUlB>ig^glYqtbC%vb0!*NwX_ZqvN+*ez(HXOOGt94vens8?E z+?znjGQr!iK5|!Y@1;U~kl)xQ*uJrkFVAY?qS9eewt4yamB%Q}$B5$}b}p44i``u* z*8SW{467|!ST1cDg zIyaOYSHJ_Vz}S@<6{S6o^LdOO{Jt(+XzVgoYM)4wdm%FDjo`QRgip{hmUH?JBU$*F ztQRhqOlkRy!bV7ah~h^lxfg{qmkINi)2+xU7Mzk8;#liXC)`b`Kl%Qknz(-OfP!{@np`CF1W5{0GQm{rcUPutg6|RPsAb($6#yy~xoF)?n$P2fIag^&)PQ=_ zbr#`m2ANxy+OfsEHGN?8V!-Geo;ye4V=2TF`hb72D=@k|QP-$|!WAsO*B1Vixs;q8 z%;_;NDlQJ2eWu-FP(EldgCN0HL0MN}(AA5I6BgixyeXsJi!7%2BIhmF+Bhjm&tc~8 z+t$h0uy*XgL0A643wt*H_v-#o|C8J)&%6HnOg0gjp`_cP{m-cZgRK_a;d;tsy_}IE zCT72Y%u}ZX;iYq~X#KYc3yQyCj1-A!`w9FGLUoLmXq&Ez3g-CW;uFYNbSt*7j4kg5 z@X40Ou*W{Ria)75?Kq)-Gj^NKeFdA&5J=vjnPIyl*N;Hq8Btpx$Tv<6)g*|!5_nh& zA8T366tb8@T**9_G?SFsETmKjO{gtj-o!f@AQ-cO&rqtfqrPSbv&#esvhHE~8b&*I zh4zN23wlGf(Tl& zlWk^^O9yDPjkKDk^2w$TAzR@lH)8w8qUOgCMu)lMdEdD{7^+Voj%9?-R0C?18Yu~t zO5eRIT{UDjvjZpEQMwJl%CL6aB>QEdqi+_3YPp@I`a5sRyf?*Ex+;>*U(K9e-KE$+ zc=Jxr((Fa^$k7AX-m7XCFJ8Rratx3L)$;BvBU)Sc=g%HwGL=r>`zB|H&cYOX$v<=H zWA?JZ-jL3Gs4he4j3IG?UcBsYfmJ6ybT6Pv^Kg^{sY;F1?5SNHVXd@HCl=~>Wi&#! zVNcwsC*$h4Dy*OyH&b2OH^J1(>NO9K$Y}62Tbd(zK<95kXuJkd=m!bZC z3=3l^Qc?Ea@0xdhk6GA^E<$%t<y5i4gq7i`LUI*7sQfZ`Vd+BipefL)MZZ6011! zB7UL}f2T8|rE_>8ZpdbreV@HB$9|IXE=F7A=BK7|eDHMN>}k@-xu{F?^MVrS znI&v$c{g=tBVlo4X)lYpHQRx9d6S}jre!0wbeuZa_?5V{KG1Q2K9jJgsR3uw$d6 zyCT_V0dAOc7n!wZX-ogYyxvI>hEkfYFa}XMht-FMAM4{=v6DK(>79e9+5nn+uCe@k zxNg#DD&fs$g~DG@I^3@@i;wtnJ7PDPOvaRcJXyBEP!Mcbe{;2Z35lm86bk(U{2iZ8 z*XUWRL}yz~`II&{m9N2MZMZ^Dc7&vOkQUKXAEoIK_U>0-89K&bP2-DF2a$b;r{7p_f7Y0J42wf=nr8#lx;>_^>s*60fmw|{hJ<& z&jVrisG&q;xX{F0IH@K6a*S`!owYCh{EE2++GFgKM$6&??>5B2@Zy8` zjN7=$0Zo>99Rz(-{ezLx|Em2V|GKY4*8 z7&PP!%Ge5*J9I$&XZFM)t;H9|f|+ySElIRlueu-wxG^jl(_c>!)h|++iy9n32SMC{ zct}r>aI2=KR{w#ljY~OWs6BD#`na{&2r)L0>AvaF^SG-X&GM0zV-z`ro|sNNc`rdS zN@#Tn?X8tatgDUAJU#RF1pQ&X<^2BA;gjXMl(}=1AAIi-7DovK&1oh*q@jE1W-zEgm2i)!gz_|5@R_ibfjVk{mnjuB{j3c|ro|1X)~#E&z{d%R9PAo-%Iq;pO|`Zp z(9{(BD}6t;?N_=xG_!V`yo{~wb1tpO)B3Wsj^!5+@WE;5=C%cF*XO=LqP2iXf>nfM zQvFd0^1Er`?uconNF(Nq0kdjgVEd5Svh^y-j=Qq}BL^8SYteBIqMC{vxkjL%m3q z@v$7nEnj$bQeryCqG0tPcf=c!kbtssV>o+I%ei6n&hWjQP+$t2w*u>wV<(+V0ZvS5 z`)O^91vs(EE%*Mee9sv1|aQlnm`DsIb2-VKBuXe1<} zlR+ZMdWGa6qN!%rCO2^f5^g=Ybc`Gb0%JyGIrq|GgpcF$7n2N-oE#I@gg!5*iWc)c5p(KMH%{RF(@50 znEu&S{By+LN|g25{QcV1u$8b*9h!_M`C*Zug2X?KlA zDU-XZk(lXXvy|L?$#>ALpmzp|=Q@e-;{Tqz@O!#B@s?0L;wsuH=p}29wPtwA#4R+* zA3+W#JC?9BLi<(%&)Ktabx~0f(E5HXWdbX=P@%kJ^Cm~Wf3mv(WP;q^AzSa%qIy9~ zE^wlghM#FQuEq9ML|A+0pU*2StBq6BL(zs$IT@&2*UemA$-JQp4A|Y8Mg+ZrpZeML zo-$mv|1^8{Gy~##zlJEhgOjedp7*p)K|RL#&DA(SnFFUmh8rH=4HOFQhq@dRcx$me z73h8~Sa!4cxlff(NA=@R!L>$f{_4nEThRpgKV3%<)%p1ouKzJ%0gqx9G^l`@GDupW z+VX`j^R+tuOfi5mX+Hos4du64`7fYjoGh0*cQ07v(yUJolM{}xJclv51*yNrB)zaF zP6j{S7@IGq2#R|gROtuzxxA@4aC(pF#&pZD#IxDv(vU`P7FYB!6%SxMH_9%H#V&H)Z%Z#_z|JD zlI;ED*QB%~t2Oc=h0l_Y z>|Q?@C1b-Zctnt|b5~kG*8pimdKD-lly=qz%n5yG;?%)uf%9eRYW012u;#3jsb*0k zGguVJagFl#8Z(HAVw_6?ua}is*=JbShL>G_yen$z9OYi~aEL261UheOL}ThOK{`*6 zL+pxFdoJRKUtWC&Gyo(+HE!YP34N&QGM!hSR^5CQVq#is+6~qS3dvzrfR+~`jfakp z+QyFS=sap01(bhXR?u=9%dSC$2jU<}=a01!nE;KA1Zxc=tlK<{44!I8b)^EG1Q+T zy?y%@1qDH+h{Kiaqew$3ZDJWKSF-wYIN9+9D3VL2R9?@9r?!7wdssd({wCgW732-b zusIPZ*1jtL{K}Zt1{p#Srw~S>zJ;{$l(8D? zZgqoL=SGm`e&~lA?<@!AlrsU8i6>gIJx=M}DdQyJW|MV0ie9>8(dLlO*|yRSW2=z0 zL95}>r|?=?<48)wK(fqy&J}+aLA6+7kf|lBdgvIkXPGKyw<09V165xp=sRf(7Ma9D zM*1)g}`rd#jCQE`0;HL&#bW94gTN__2bc%K7O7W zU@*Qjl&+%j&1pw{?;&C#AWH>9Banx1XZKC0p>0z9&gTTtA6MPZX#JrX}Iyz>c1_eEl${0|Ur~E-^(;p(442C0MD|Dx!>i{ais@Clo zGBZA{hEW0a)~LbfBVq1UL*h0jdS5y=LXv(vu&7? zx$$DiMl&EsNXG2Wn*LxY{;((Y~Ca(Y<^+88<+R7d<)i#ncXJ%~l^cOX|>TmW5K#GvVd zr=;|{Wwlr4NO9Ce@j`mrqNbBC#;gb-*s(H}iu$-=m0?$lYaJh6o4Z=YdV zD?g<@NfdPjozR!bm+f&Bx^Js2Syy2a5UJRi&bY-CH8BCO_=E5^lpLS~w1qFnaE~tsG$rfEbK z!*atvK~|K|1(ZUvBgV#g7y3$A3^qcZBY?&igY+N*nnc6J#6LTI0f7)tZ|Umm8!ZI= zUBXVHiM)_J!c40g@>D0e+yZ>=1CVOKWT3$%l2q1<3?N}Rf?+*e8thr%K-Kbjfnf73 zITosEAgKB0Q5ps?r>=2Azs~CG-0*q4sqVVZLZ;*kCNJb9VT zWe<&LpT{Mx@Dup;BG{Ws!CNq%5F#j<;=XVL@3zcK+z(ns&|!yb8r6_CmpLq7zQhVo z`yWwlqj$WcmNrrYnMj#}D`gzNL&h%t>S#FSCqI1-l$|A(1%7D&V`QVu@eo=~y>!@N z;zI`}FDsKCZhh2jeF+nyoFc+G4Uf1 z3cyx|+||EX8&T@gH%(t`whRFei zBM5?}26M{x*UExe6%9wx4;s1Ut&R5g zBQkv+lb$p~Z?Efm-tq3J_R<-SV4>bB6A8^Bko`{IYus7eO2f5{MRLY?g|7TUFzMOd z!d4Fr&(9wlPH_a8+_}tj>&VAK7+i@}5`?$w^DUi%0lbXXcsvrm-rLwyp&F_Cpa|K^gxhXiZ^y;qGT9o71RjN1==J3&|H zon{MQ0`Mm1sH&mPKQ^M_(|RjTkgUfG$RE^bHCGr({_&hIG!C&nS_L(*b7S7=?{ z&u2!Te4C0PEuJHV!HlQxaoI`osrDKWP{19*rg*@Vykd?b&!U^nR;n;f#2^b4q^yUp z?;Xcgp*=xLGV_%n9wh_;?Iz-GKKfkKI={|4$GjR61{VGDG>rVj`vq@dg0(25ERX{t zm*}EOZIK+r{@CJQ4$=c!qJAZQD%2WAQ&V%jN;-~`=?Vxo{0T4qgoIJyFIVV2 z;NpwWC=Ue`+4+_u`VIr&eUBoX{hBNKo;|adT?PHhkavwBz19;nwiOTzbLHh<9bbf* zsD-HM*+Z+>&jQbeG##VeOoJhp(Vt2v6>i>9M_d>jdZf94P4my<=FUdcrc##E55CD6 zvSLqIF?hE$kVf~6*S2Uyx@x>V8;n@!#I}LViJZ6bw569c56xq`yqVnTnQgm&La6z1 z{45eXKoj69Zzghw^eBURP|HpR$fI?jZYg|)rcxlFkP!07hZ&~rw2n-WJS!5ovv;mB5T#IaARaFXo z%#tgW~|2vg5*_;gUxbc&0bUkl_|Fqen4T zj%tYM2ESa*TB3&1MtSyYuHsfvR+-9SppvbZxWqjNwW_eI_u6q2mtgGkY{8n=t!V$Ux31 zn|A`o5OG0uxsd`iWua1T%0^<8o2g?kx>0n?&v)x+!)&^B9d;DS+&g7mcX%m;xfH1C zGL23b@>cbIcsBw;nFVq>sO4a1U`v`Wj3dMCU_O9HzX4&^!nNjx1rB zyYzV?=Mr**8I>^sVe7$x$Ze2`^DcGr9yA**ng8sIVu*Ay`kjOp9$^gzA$U2FjMS0m z8iYZZP2O!k9#<8kHM>oQ>BXd`VmHvEF){MchDiD9|T2byGygKsqHv=GO-g-NSlNcV7o|Za`}T07JZj5t)}DgbW~ z2Sycf!Eev~8u%UAK*;U~w368>c!h?dFqN+7g>~U&ql2|~WP~DQ%muuYmC;C(W3jSw z{@vfn7tFGeD~*UgerOB-ua5#M0JQ7fp#~0TAG#UpNoZY=&awS9JT+%>Z=o}o2B>vF zdibHCK0q9*P=+9r%qSs0@N4Xt--s60=^AR4-IjjY+gr&FVJ&il(d&-7;oyOSMWov| z@|T1A%ME|3Fl6=`oU^R5lL(#>^T|NW2U!s?Z-PPrj{#9FfGr~Gn?3^7y&C-P^xC=` z_i7ajUmce@KH{>-eH^@3p%ZjnbK=q(WT;<_e2_avF9PNv5FG3J*Y}?SKXrr37;eqXIz5M!}FA9x;=7U%B zZLBEMw8lv}6Oi!8MnZP5Xp{dH%*Py9AR0fS;dren^!OI~>Gf#c_tY>jb0~*%8c8x8 z1R>C09_?g+j3|gs{0^1M2vct;M)(4h8nodx@)q^iKx}$F-UYeW@jKwz$AITH|9j#Ps9x}k!4lKI2@^zcVYi{<4>03G zgiYpPz21rW2@Gx7Ujtr)dj!FQJ_Ker_^3&?XoiFA$E*#v+(F?IL7jkQh6ec&sTe=`GbjGwl3}^B-26sx z^PqsB!2aQbUb|#;4>ST>aMk6uKipUI*e&-bg+E?~IR;Al{~jKZ1ET9jNw$EcU zulnzqj~#K%$Wlj|I1U}fxC@q*pDFl(wtqW4E3AY2{em3ZY9mO@MA`O&E9|(6eh}`q zNzphd_Q}K{h&ChzIw74laxyDLWZ_X*jH)2q!e) zGG+Lrw2w}Z=H*;CbhQ!&y8uwK2Q^NT4YqP7cEe#T%5pE3$e_Cpxk!m=P=)ZX*Dk@G z6{ttNC{%?fPQ*YoKY$6>cq)?$77CgyH-I$+)+wgwfr5?fPyv`m$fpDL)UgGWFglE) z57MuP@OYP#Z`Y5ql0^9~_De!{@x9lvC2ge*n8km9KeRhAX@_?^EK}IgE5V>!@x({_L}6k$lK}YkhY;Ca~QDuWfw=^C<+Tu zsRT1-oMKBEO9=)#1tAYW|gWvq|>(N1(Fn3)Z*Iff$ZATnM$%}0yxErvHQW^Hs^@jjsF4sKOl$z literal 0 HcmV?d00001 diff --git a/crates/render/tests/golden/drop-shadow.png b/crates/render/tests/golden/drop-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..711f91be8260491a9d4ba01bc89899807d4b46fe GIT binary patch literal 3156 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 literal 0 HcmV?d00001 diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..87bc812b5920c741bd9845c826847f321c2cecb5 GIT binary patch literal 1867 zcmeAS@N?(olHy`uVBq!ia0vp^3qY8I4M=vMPuFE&U>EXqaSW+oe0zX-flB1GV;}#Q zzw145W_gi!^19sH+n^}3XWlfg_4AC3mEQd<+n!amtlc{M>iSz|;jv%uH$Pal`rmJX zn9{5D@=dGP{d(_Qa5eOQEyucDulDmF3=RMLU2(^&RsZc+K#JQ3M)B1DYU_V+>HD*I z2ry34WK{`fQW0iMP?@m9=h3SF4rbH;ep>&}^gr*4Kjxhc932fD!iiopf_&BrBziF# z2r{@a6<)2^N&I}j^;G?24h>c%AI)WAmrIw01+QLtbV`y+FqcLT3(z19vxW^n-#b0s z@5$l<)UNA!fA&7}&G$-TFO@D^t#)Wil874btawd zP1%@nCf)PCdToA|so*k>2@@TdI#>UjnzL@h21VYRN9u*O{`)HVw5+`PW$w(glY4uQ zT}#`v^JavO)aHGL^JlLE+QRAJ#>A~l)-=8b!=-ncO%;*9j?nKQpG3ll!-0n>DlO(!RVb8Y8;HK5}>)DB%q`gQM2dYYlX znVD4d?B2C&j~!cNINS7@F)*-LRhoNPgu?%7-%McUkTyDM0@3B-pec};Wf(jCORdrS z$w&8W+?bJ*Yx->Sv6{SHSy_!72u&S6Cqyo*{F@!{Ge22HI5Ep~>#Tj|e|3O9JE|0U zW^%8q(QH*`!Ke_vqSPIPDrQUFH7u5%^8;CQ(^#mqZxbBdk! zuX|IHlz z2ybdQQZKIczn8^B!I5Q31K18PLseiZ0fxJa1Sc?^y^24{Hv6=ZqIAk9P%07zia9Vb zCLHi^Gn&+CAPBO8L%9(eHlK6aIR5Pp_$luIj2NIgIAH{G;(GQHP!=KR1D4nz6ARCle_p5xv!DmK59;g7SXMhMhG%zp%%cYD*T)-s5IrV=U%s6MY z6ekdx?y1mtB=o-*(Bx^HGbcRT&kZao&49X?T6A+JY8tsNJ0UVtiFa)yFEE_}Q`aP5 zsdb{|noBm=I5nVg&T2~(gSV(f313!O=Gik@V=)7;d~|4l&I}54_ j6b8H|AQ;z&?U`&%u1^-8W3(7pNiul4`njxgN@xNAmFKIc literal 0 HcmV?d00001 diff --git a/crates/render/tests/golden/outer-glow.png b/crates/render/tests/golden/outer-glow.png new file mode 100644 index 0000000000000000000000000000000000000000..461bfb694033193bb0e40f36eaea08ab8de8c9ba GIT binary patch literal 3367 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 LZ(2X7H`)FLfhm{$ literal 0 HcmV?d00001 diff --git a/crates/render/tests/perf.rs b/crates/render/tests/perf.rs new file mode 100644 index 00000000..a8b63de3 --- /dev/null +++ b/crates/render/tests/perf.rs @@ -0,0 +1,122 @@ +//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock +//! timings are only meaningful on an idle machine — run with +//! `cargo test --release -p designcraft-render --test perf -- --ignored`). + +use std::time::Instant; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, ParaFormat, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +const WORDS: &[&str] = &["grid", "margin", "column", "rhythm", "baseline", "reader", "story", "frame", "quiet", "layout", "type", "texture"]; + +/// `pages` pages, each with 6 frames of one threaded story. +fn text_doc(pages: usize) -> Document { + let mut d = Document::new(&NewDocument { pages, ..Default::default() }); + let lid = d.default_layer(); + let mut text = String::new(); + for i in 0..pages * 3000 { + text.push_str(WORDS[(i * 7 + i / 5) % WORDS.len()]); + text.push(if i % 70 == 69 { '\n' } else { ' ' }); + } + let mut prev = None; + for abs in 0..pages { + let (si, pi) = d.page_loc(abs).unwrap(); + let x0 = d.spreads[si].pages[pi].x; + for k in 0..6 { + let (c, r) = ((k % 2) as f64, (k / 2) as f64); + let rect = Rect::new(x0 + 36.0 + c * 280.0, 36.0 + r * 240.0, x0 + 296.0 + c * 280.0, 266.0 + r * 240.0); + let t = if prev.is_none() { text.as_str() } else { "" }; + let (id, _) = d.add_text_frame(SpreadRef::Doc(si), rect, lid, t, ParaFormat::default()).unwrap(); + if let Some(p) = prev { + d.thread(p, id).unwrap(); + } + prev = Some(id); + } + } + d +} + +fn placed(d: &Document) -> Vec { + let mut y = 0.0; + (0..d.spreads.len()) + .map(|i| { + let b = d.spreads[i].bounds(); + let p = Placed { spread: SpreadRef::Doc(i), offset: Vec2::new(-b.x0, y) }; + y += b.height() + 36.0; + p + }) + .collect() +} + +#[test] +fn glyph_paths_are_cached_and_offscreen_text_is_skipped() { + let d = text_doc(4); + let cache = Cache::new(); + let all = placed(&d); + let mut r = Renderer::new(); + r.threads = 0; + // 200% view of the top-left of the first spread: only a few frames are visible. + let view = Affine::scale(2.0); + let opts = RenderOptions::default(); + let a = r.render(&d, &cache, &all, 800, 600, view, &opts); + let drawn = r.stats.glyphs; + assert!(drawn > 100, "glyphs drawn: {drawn}"); + assert!(r.cached_frames() <= 4, "only visible frames get glyph paths: {}", r.cached_frames()); + // Same pixels from the cache. + let b = r.render(&d, &cache, &all, 800, 600, view, &opts); + assert!(a.pixels == b.pixels); + // Culling: far fewer glyphs than the visible frames hold. + let whole = r.render(&d, &cache, &all, 2400, 1600, Affine::scale(1.0), &opts); + assert!(r.stats.glyphs > drawn * 2, "{} vs {drawn}", r.stats.glyphs); + drop(whole); + // A new composition (edited story) gets fresh paths. + let mut d2 = d.clone(); + let sid = *d2.stories.keys().next().unwrap(); + d2.set_story_text(sid, "Edited").unwrap(); + let c = r.render(&d2, &cache, &all, 800, 600, view, &opts); + assert!(r.stats.glyphs < 10); + assert!(c.pixels != a.pixels); +} + +#[test] +fn greeked_text_builds_no_glyph_paths() { + let d = text_doc(2); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() }; + let img = r.render(&d, &cache, &placed(&d), 400, 300, Affine::scale(0.3), &opts); + assert_eq!(r.stats.glyphs, 0); + assert_eq!(r.cached_frames(), 0); + // The grey bars are drawn. + let grey = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 240).count(); + assert!(grey > 200, "{grey}"); +} + +#[test] +#[ignore = "wall-clock budget; run on an idle machine"] +fn render_text_heavy_spread_at_100_percent_within_budget() { + let d = text_doc(8); + let cache = Cache::new(); + let all = placed(&d); + let mut r = Renderer::new(); + // 100% on a HiDPI 2880×1800 canvas = 2 px/pt, centred on the second spread. + let view = Affine::scale(2.0) * Affine::translate((0.0, -828.0)); + let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() }; + r.render(&d, &cache, &all, 2880, 1800, view, &opts); + let mut v: Vec = (0..9) + .map(|_| { + let t = Instant::now(); + drop(r.render(&d, &cache, &all, 2880, 1800, view, &opts)); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + v.sort_by(f64::total_cmp); + let median = v[v.len() / 2]; + eprintln!("render text-heavy spread at 100%, 2880×1800: {median:.2} ms (glyphs {})", r.stats.glyphs); + // plan/architecture.md §9: < 16 ms. + assert!(median < 16.0, "{median:.2} ms"); +}