PDF export: look internal links up in a map, not the whole document (#937)

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) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 09:19:41 -04:00
1 parent da5da66e51
commit 912d49f38f
3 files changed
+52 -1

No files matched your search

+1
View File
@@ -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) |
|---|---|
+13 -1
View File
@@ -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<HashMap<String, wordcraft_doc::Pos>>,
}
/// 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)
{
+38
View File
@@ -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)");
}