Files
designcraft/apps/designcraft-cli/src/stress.rs
T
NetterSilverandClaude Opus 5.5 1db1635a88 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 <noreply@anthropic.com>
2026-10-27 16:47:25 +01:00

245 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8> {
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<Document, String> {
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<AssetId> = (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<ItemId> = 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)
}