From 1bddba0e7578b79a8eba2db7dbaba2175dfdc450 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:07:21 +0200 Subject: [PATCH] Compose: cross-reference index no longer rebuilds itself (#851) The cross-reference index composed its destination stories through the cache. One with a running header asked for the running-header index, which composed the story holding the cross-reference, which asked for the cross-reference index again: the stack overflowed and the app aborted. Destination stories with a running header are now composed without it, like those with cross-references already were. --- crates/compose/src/xref.rs | 8 +++++--- crates/engine/src/cmd/xref.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 4bce48d2..b3371420 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -19,15 +19,17 @@ pub fn has_xrefs(st: &Story) -> bool { } impl XrefIndex { - /// `get` composes a story (normally through the cache). Stories that themselves hold - /// cross-references are composed without resolving them (their anchors' pages hardly move). + /// `get` composes a story (normally through the cache). Stories that hold cross-references or + /// running headers are composed without resolving them (their anchors' pages hardly move): + /// through the cache they would build this index again, directly or by way of the running-header index. pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc) -> XrefIndex { let mut pages = HashMap::new(); for (sid, st) in &doc.stories { if st.anchors.is_empty() { continue; } - let cs = if has_xrefs(st) { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; + let indexed = has_xrefs(st) || crate::vars::has_running(doc, st); + let cs = if indexed { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; for a in &st.anchors { let Some(pos) = st.anchor_pos(a.id) else { continue }; if let Some((page, ..)) = crate::vars::place(doc, &cs, pos) { diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index b0b97ebd..54e5dd6b 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -214,4 +214,23 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + + #[test] + fn cross_reference_into_a_story_with_a_running_header_composes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); + let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); + s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); + s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); + s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); + // The cross-reference index and the running-header index used to build each other without end. + assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); + // The header shows the body's paragraph in front of its own text. + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); + let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); + assert_eq!(glyphs, "SeeChapter".len()); + } }