From 1db1635a8831d2a890fd09f13fd4699b6eaf0c45 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:36:35 +0100 Subject: [PATCH] Ask the composition cache once per story and page in PDF export The PDF exporter called Cache::get twice for every text frame it drew. Each call rebuilds the cache signature of the whole story, which looks up every frame of the story with Document::find (a linear walk over all spreads), and the anchored-frame placements walk them again. For a story threaded through many frames this is O(frames^2 x items) per export. The document doesn't change during an export, so the exporter now keeps the composition it got for each (story, page) and reuses it. The cache still decides what to compose; the output is byte-identical. Add examples/pdf_export.rs to designcraft-cli: File > Export PDF through a headless session on the `designcraft-cli perf` stress document (200 pages, 1M characters in 40 threaded stories, 500 placed images). The stress document generator moves unchanged from src/perf.rs to a small library module (src/stress.rs) that the binary and the example share. Ryzen 16 threads, Linux, 12 runs each, interleaved: main median 3485 ms, min 3427 ms patched median 2016 ms, min 1656 ms (-42 % median, -52 % min) The exported PDF is byte-identical (12,075,744 bytes). Co-Authored-By: Claude Opus 5.5 --- apps/designcraft-cli/examples/pdf_export.rs | 64 +++++ apps/designcraft-cli/src/lib.rs | 5 + apps/designcraft-cli/src/perf.rs | 243 +------------------ apps/designcraft-cli/src/stress.rs | 244 ++++++++++++++++++++ crates/pdf/src/export.rs | 3 + crates/pdf/src/export/booklet.rs | 1 + crates/pdf/src/export/images.rs | 1 + crates/pdf/src/export/pipeline.rs | 1 + crates/pdf/src/export/walk.rs | 10 +- crates/pdf/src/tests.rs | 27 +++ 10 files changed, 358 insertions(+), 241 deletions(-) create mode 100644 apps/designcraft-cli/examples/pdf_export.rs create mode 100644 apps/designcraft-cli/src/lib.rs create mode 100644 apps/designcraft-cli/src/stress.rs diff --git a/apps/designcraft-cli/examples/pdf_export.rs b/apps/designcraft-cli/examples/pdf_export.rs new file mode 100644 index 000000000..c427741a2 --- /dev/null +++ b/apps/designcraft-cli/examples/pdf_export.rs @@ -0,0 +1,64 @@ +//! File › Export PDF on the `designcraft-cli perf` stress document, timed. +//! +//! ```text +//! cargo run --release -p designcraft-cli --example pdf_export -- [--pages N] [--runs N] [--out FILE.pdf] +//! ``` +//! +//! Builds the synthetic document (200 pages, 1M characters in threaded stories, 500 placed +//! images, gradients, a drop shadow per spread), opens it in a headless session and runs +//! `file.exportPdf` on all pages: one warm-up export (composition), then `--runs` timed ones. +//! Prints each run, the median and the minimum. `--out` keeps the last PDF, written with a fixed +//! creation date so two builds can be compared byte for byte. + +use std::time::Instant; + +use designcraft_cli::stress; + +fn main() -> Result<(), String> { + let mut spec = stress::Spec::default(); + let mut runs = 7usize; + let mut out: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let mut num = || args.next().and_then(|v| v.parse::().ok()).ok_or(format!("{a} needs a number")); + match a.as_str() { + "--pages" => spec.pages = num()?, + "--runs" => runs = num()?.max(1), + "--out" => out = Some(args.next().ok_or("--out needs a file")?), + other => return Err(format!("unknown option `{other}`")), + } + } + let doc = stress::synthetic(&spec)?; + let bytes = designcraft_format::save(&doc).map_err(|e| e.to_string())?; + let dir = std::env::temp_dir().join(format!("designcraft-pdf-bench-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let file = dir.join("stress.designcraft"); + let keep = out.is_some(); + let pdf = out.map_or_else(|| dir.join("stress.pdf"), std::path::PathBuf::from); + std::fs::write(&file, bytes).map_err(|e| e.to_string())?; + let mut s = designcraft_engine::Session::new(); + s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; + let params = serde_json::json!({"path": pdf, "created": 1_700_000_000}); + let mut export = || -> Result { + let t = Instant::now(); + s.execute("file.exportPdf", ¶ms).map_err(|e| e.to_string())?; + Ok(t.elapsed().as_secs_f64() * 1000.0) + }; + let warm = export()?; + println!("{} pages, warm-up export (with composition) {warm:.1} ms", doc.page_count()); + let mut ms = Vec::with_capacity(runs); + for i in 0..runs { + let t = export()?; + println!("run {}: {t:.1} ms", i + 1); + ms.push(t); + } + ms.sort_by(f64::total_cmp); + let size = std::fs::metadata(&pdf).map_or(0, |m| m.len()); + println!("median {:.1} ms, min {:.1} ms, {} kB", ms[ms.len() / 2], ms[0], size / 1024); + let _ = std::fs::remove_file(&file); + if !keep { + let _ = std::fs::remove_file(&pdf); + } + let _ = std::fs::remove_dir(&dir); + Ok(()) +} diff --git a/apps/designcraft-cli/src/lib.rs b/apps/designcraft-cli/src/lib.rs new file mode 100644 index 000000000..f63ceb415 --- /dev/null +++ b/apps/designcraft-cli/src/lib.rs @@ -0,0 +1,5 @@ +//! Headless DesignCraft's shared parts: the synthetic stress document of `designcraft-cli perf`, +//! which the PDF export benchmark (`examples/pdf_export.rs`) exports too. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod stress; diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 6f80cb356..fbc7d00d9 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -10,253 +10,18 @@ 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_doc::{Document, SpreadRef, StoryId}; +use designcraft_geom::{Affine, Rect, Vec2}; use designcraft_render::{Placed, RenderOptions, Renderer}; +use designcraft_cli::stress::{Spec, synthetic}; + /// 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) -> Result { - 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::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))], - }, - }, - locked: false, - named: true, - hidden: false, - }); - 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(), - data: Arc::new(data), - pixels: Some((aw, ah)), - ..Default::default() - }), - ); - 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).map_err(|e| e.to_string())?; - // 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()).map_err(|e| e.to_string())?; - if let (false, Some(p)) = (first, prev) { - d.thread(p, fid).map_err(|e| e.to_string())?; - } - 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 { - layer_options: None, - 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), - clip: None, - auto_fit: designcraft_doc::Fitting::FillProportionally, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - if k == 0 && pi == 0 { - it.effects.drop_shadow.on = true; - } - d.insert_item(sr, it, None).map_err(|e| e.to_string())?; - } - placed_images += want; - } - Ok(d) -} - fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { let mut v: Vec = (0..runs.max(1)) .map(|_| { diff --git a/apps/designcraft-cli/src/stress.rs b/apps/designcraft-cli/src/stress.rs new file mode 100644 index 000000000..c9d5b3015 --- /dev/null +++ b/apps/designcraft-cli/src/stress.rs @@ -0,0 +1,244 @@ +//! The synthetic stress document `designcraft-cli perf` measures, also used by the PDF export +//! benchmark (`examples/pdf_export.rs`). + +use std::sync::Arc; + +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; + +/// 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) -> Result { + 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::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))], + }, + }, + locked: false, + named: true, + hidden: false, + }); + 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(), + data: Arc::new(data), + pixels: Some((aw, ah)), + ..Default::default() + }), + ); + 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).map_err(|e| e.to_string())?; + // 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()).map_err(|e| e.to_string())?; + if let (false, Some(p)) = (first, prev) { + d.thread(p, fid).map_err(|e| e.to_string())?; + } + 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 { + layer_options: None, + 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), + clip: None, + auto_fit: designcraft_doc::Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + if k == 0 && pi == 0 { + it.effects.drop_shadow.on = true; + } + d.insert_item(sr, it, None).map_err(|e| e.to_string())?; + } + placed_images += want; + } + Ok(d) +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 87beb0488..164e4c8f4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> { /// The document's inks with its Ink Manager settings. pub(crate) inks: craft_prepress::InkSet, pub cache: &'a Cache, + /// Each story's composition per page it is shown on, for this export: the document doesn't + /// change while it runs, and asking the cache checks every frame of the story again. + composed: HashMap<(designcraft_doc::StoryId, Option), Arc>, pub opts: &'a PdfOptions, /// Current sheet's visible area (spread space) for culling. pub clip: Rect, diff --git a/crates/pdf/src/export/booklet.rs b/crates/pdf/src/export/booklet.rs index fa1aa0655..bc2138623 100644 --- a/crates/pdf/src/export/booklet.rs +++ b/crates/pdf/src/export/booklet.rs @@ -98,6 +98,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts: &pdf_opts, clip: Rect::ZERO, warnings, diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 00352a6f1..a5b7eda8c 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -198,6 +198,7 @@ mod image_content_cache_tests { inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts, clip: Rect::ZERO, warnings: Vec::new(), diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index 18c88dbfd..ca9d1a988 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -172,6 +172,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions inks: crate::inks::ink_set(doc), doc, cache, + composed: HashMap::new(), opts, clip: Rect::ZERO, warnings, diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index 08bc05c93..686fbdb24 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -4,6 +4,12 @@ use super::pipeline::{item_exported, layer_exported}; use super::*; impl Exporter<'_> { + /// Story `sid` composed for `page` ([`Cache::get`]), asked of the cache once per export. + fn composed(&mut self, sid: designcraft_doc::StoryId, page: Option) -> Arc { + let (doc, cache) = (self.doc, self.cache); + self.composed.entry((sid, page)).or_insert_with(|| cache.get(doc, sid, page)).clone() + } + /// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases /// first, then the parent. fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) { @@ -325,7 +331,7 @@ impl Exporter<'_> { } } Content::Text(tfr) => { - let cs = self.cache.get(doc, tfr.story, page); + let cs = self.composed(tfr.story, page); if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { @@ -381,7 +387,7 @@ impl Exporter<'_> { s.pop(); } if let Content::Text(tfr) = &it.content { - let cs = self.cache.get(doc, tfr.story, page); + let cs = self.composed(tfr.story, page); if let Some(ft) = cs.frame(it.id) { self.frame_objects(s, ft, xf * doc.text_local(it), page); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b06fad47d..a5c19edc5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -890,3 +890,30 @@ fn a_swatch_colours_a_greyscale_image() { let (s, _) = export(&rgb, Some("PANTONE Test")); assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours"); } + +/// The export asks for each story's composition once per page it is shown on: a parent page's +/// page number still reads its own page everywhere, and a story threaded across pages shows each +/// frame's own lines. +#[test] +fn each_page_shows_its_own_composition() { + use designcraft_doc::story::PAGE_NUMBER; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..NewDocument::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, 300.0, 740.0), lid, &format!("Folio{PAGE_NUMBER}"), ParaFormat::default()).unwrap(); + let (first, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 56.0), lid, "Alpha\nBravo\nCharlie", ParaFormat::default()).unwrap(); + let mut prev = first; + for si in 1..3 { + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 36.0, 300.0, 56.0))); + d.insert_item(SpreadRef::Doc(si), it, None).unwrap(); + d.thread(prev, id).unwrap(); + prev = id; + } + let cache = Cache::new(); + let opts = PdfOptions { created: Some(0), ..PdfOptions::default() }; + let bytes = export_pdf(&d, &cache, &opts).unwrap(); + // Parent items first, then the page's own. + assert_eq!(extract_text(&bytes), ["Folio1Alpha", "Folio2Bravo", "Folio3Charlie"]); + assert_eq!(export_pdf(&d, &cache, &opts).unwrap(), bytes, "a warm cache exports the same bytes"); +}