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"); +}