From f093f74edc89b9edfbdd1b68198f9aa80ec2333d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:54:46 -0400 Subject: [PATCH] PDF export: look internal links up in a map, not the whole document Found while timing US government documents against Word for Windows. Before: every internal link (#bookmark: a linked table of contents, cross-references) found its bookmark by walking every paragraph of the document, so export time grew with links x length: NIH's 67-page protocol template (297 links) took 10.6 s to export as PDF. After: the bookmark positions are gathered once per export (the first bookmark of a name still wins). The same file exports in 0.9 s with identical text and links. internal_links_export_in_linear_time guards it (the old code took 12x as long for 4x the document). Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/pdf/src/lib.rs | 14 +++++++++++++- crates/pdf/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index fdefee6..731d771 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | | 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents | | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | +| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 194b47e..6b370fb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -156,6 +156,7 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou tags: Vec::new(), tag_index: HashMap::new(), links: Vec::new(), + bookmarks: std::cell::OnceCell::new(), }; if let Some(o) = ex.outline(pages) { pdf.set_outline(o); @@ -522,6 +523,10 @@ struct Exporter<'a> { tag_index: HashMap<(StoryRef, DocPath), usize>, /// Link rectangles on the current page: (x0, y0, x1, y1, target). links: Vec<(f32, f32, f32, f32, String)>, + /// Bookmark positions by name (the first of a name wins), gathered once: looking each internal + /// link up by walking the whole document made a long document with a linked table of contents + /// take seconds to export. + bookmarks: std::cell::OnceCell>, } /// What the content being drawn is, for tagging. @@ -1210,7 +1215,14 @@ impl Exporter<'_> { /// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which /// in a paragraph split across pages need not be the paragraph's first page. fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> { - let (_, pos) = self.doc.bookmarks().into_iter().find(|(n, _)| n == name)?; + let marks = self.bookmarks.get_or_init(|| { + let mut m = HashMap::new(); + for (n, pos) in self.doc.bookmarks() { + m.entry(n).or_insert(pos); + } + m + }); + let pos = marks.get(name)?.clone(); if let Some(c) = self.lay.caret(&pos) && let Some(out) = self.out_index.get(&c.page) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2fd44dc..5ce20dd 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -821,6 +821,7 @@ fn bookmark_destination_is_the_page_of_its_start() { tags: Vec::new(), tag_index: HashMap::new(), links: Vec::new(), + bookmarks: std::cell::OnceCell::new(), }; let (page, x, y) = ex.bookmark("target").unwrap(); assert_eq!(page, 1, "the bookmark is on the second page"); @@ -864,3 +865,40 @@ fn comments_print_in_balloons_beside_scaled_pages() { assert!(squash(&extract_text(&bytes).concat()).contains("On the heading")); } } + +/// A long document whose every paragraph links to a bookmark (a linked table of contents, cross +/// references) exports in time proportional to its length: each link looked its bookmark up by +/// walking the whole document, so NIH's 67-page protocol template took 10.6 s (0.9 s now). +#[test] +fn internal_links_export_in_linear_time() { + let doc = |n: usize| { + let mut d = Document::new(); + d.body = (0..n) + .map(|i| { + // Eight links a paragraph, like a dense table of contents or cross-references. + let mut p = wordcraft_doc::Paragraph::new(); + for k in 0..8 { + let link = CharProps { link: Some(format!("#mark{}", (i * 7 + k) % n)), ..CharProps::default() }; + p.insert_text(p.len(), &format!("see {k} "), &link).unwrap(); + } + p.insert_object(0, InlineObject::BookmarkStart { name: format!("mark{i}") }, &CharProps::default()).unwrap(); + para_block(p) + }) + .collect(); + d + }; + let fastest = |d: &Document| { + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + let bytes = export(d, &PdfOptions::default()).unwrap(); + assert!(!bytes.is_empty()); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) + }; + let (small, big) = (doc(100), doc(400)); + let _ = export(&small, &PdfOptions::default()); + let ratio = fastest(&big) / fastest(&small).max(1e-9); + assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)"); +}