From ee040335241eb44229e19ab750b41756cd98963f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:41:27 +0000 Subject: [PATCH] pdf: gather a paragraph's text once, keep note line widths, count handout rules --- crates/pdf/src/lib.rs | 47 +++++++++++++++++++++++++++-------------- crates/pdf/src/tests.rs | 13 ++++++++++++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 6baeda314..922f84f47 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -498,6 +498,9 @@ impl Backdrops { /// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters. const MAX_RASTER_PIXELS: f64 = 64_000_000.0; +/// Most ruled note lines one handout area gets (a letter page holds about 30). +const MAX_NOTE_LINES: usize = 500; + /// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped. fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 { let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0; @@ -573,20 +576,17 @@ impl Exporter<'_> { /// Invisible glyphs exactly over the rasterised text. fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> { + // Each paragraph's characters, gathered once: a long paragraph wraps into many runs. + let mut para_chars: std::collections::HashMap> = std::collections::HashMap::new(); for run in &pt.layout.runs { if run.glyphs.is_empty() || run.size <= 0.0 { continue; } - let text: String = pt - .body - .paragraphs - .get(run.para) - .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) - .unwrap_or_default() - .chars() - .skip(run.chars.0) - .take(run.chars.1.saturating_sub(run.chars.0)) - .collect(); + let chars = para_chars.entry(run.para).or_insert_with(|| { + pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.text.chars()).collect()).unwrap_or_default() + }); + let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len())); + let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect(); if text.trim().is_empty() { continue; } @@ -666,19 +666,28 @@ impl Exporter<'_> { let lh = size * 1.3; let mut y = area.y0 + size; s.set_fill(Some(grey(0x20, 1.0))); + let space = width(" "); 'outer: for para in text.split('\n') { + // The line's width is kept as words are added, not measured again for each word. let mut line = String::new(); + let mut line_w = 0.0; for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if width(&cand) > area.width() && !line.is_empty() { + let word_w = width(word); + let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w }; + if cand_w > area.width() && !line.is_empty() { if y > area.y1 { break 'outer; } s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); y += lh; line = word.to_string(); + line_w = word_w; } else { - line = cand; + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + line_w = cand_w; } } if y > area.y1 { @@ -695,20 +704,26 @@ impl Exporter<'_> { /// Ruled lines for handwritten notes (3-per-page handouts). fn note_lines(&self, s: &mut Surface, r: Rect) { - let mut y = r.y0 + 18.0; + // Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page + // would want millions of rules. + let first = r.y0 + 18.0; + let count = ((r.y1 - first) / 22.0).floor() + 1.0; + if !(count >= 1.0) { + return; + } s.set_stroke(Some(krilla::paint::Stroke { paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(), width: 0.5, ..Default::default() })); - while y <= r.y1 { + for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) { + let y = first + 22.0 * i as f64; let mut p = kurbo::BezPath::new(); p.move_to((r.x0, y)); p.line_to((r.x1, y)); if let Some(path) = to_path(&p) { s.draw_path(&path); } - y += 22.0; } s.set_stroke(None); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b34e963e1..5d51642d1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -677,3 +677,16 @@ fn raster_scale_caps_total_pixels_not_each_side() { let s = raster_scale(720.0, (960.0, 540.0), 200.0); assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn huge_paper_and_long_notes_export_in_bounded_work() { + // At 1e17 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. + let p = deck(1); + let _ = export(&p, &PdfOptions { paper: (1.0e17, 1.0e17), ..opts(PageLayout::Handouts { per_page: 3 }) }); + // 20,000 short words of notes, wrapped with the line width kept as words are added. + let mut p = deck(1); + Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000)); + let bytes = export(&p, &opts(PageLayout::Notes)).expect("export"); + assert!(bytes.starts_with(b"%PDF-")); +}