From 437c53251152b651f2352edb0a4f803000bda24d Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:42:10 -0800 Subject: [PATCH] Compose: count endnote, anchored object and cross-reference marks once per story (#2705) * Compose: count endnote, anchored object and cross-reference marks once per story Each paragraph of a story with endnotes, anchored objects or cross-references counted the marks before it from the story start, so composing the story read its text once per paragraph. The marks before each paragraph are now counted in one pass when composition starts. * Compose: test cross-reference mark counts past the first paragraph The mark-count fixture now puts a cross-reference in every paragraph, so the test checks nonzero cross-reference counts and that each paragraph shows its own cross-reference's text. --------- Co-authored-by: Dylan M. Taylor --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 64 ++++++++++++++++++++++++++++++++--- crates/compose/src/tests.rs | 67 +++++++++++++++++++++++++++++++++++++ crates/compose/src/xref.rs | 17 +++++++++- 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c06cfceaa..961d69281 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -396,6 +396,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m - **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. +- **2026-11-12** · Composing a story with endnotes, anchored objects or cross-references counts their marks in one pass over the story, instead of counting again from the story start for every paragraph. No readiness percentage change. - **2026-11-12** · Center, Bottom and Justify vertical justification in a shaped frame test each moved line against the outline down to its baseline. The descenders of earlier lines may cross the outline, as the last line's do, so an outline that narrows under an earlier line no longer holds the text higher by that line's descent. No readiness percentage change. - **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ab2a9ede7..81cdad56a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1294,10 +1294,63 @@ struct SubstitutionInput<'a> { var_story: bool, var_cache: &'a mut HashMap<(Option, bool), Arc>>, notes: &'a mut Notes, + /// The marks in the story before `range`. + marks: MarkCounts, +} + +/// Endnote references, anchored-object marks and cross-reference marks in a stretch of story text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct MarkCounts { + endnotes: usize, + objects: usize, + xrefs: usize, +} + +#[cfg(test)] +thread_local! { + /// Bytes of story text [`MarkCounts::of`] has read on this thread. + static MARK_BYTES_SCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +impl MarkCounts { + /// The marks in `text`. + fn of(text: &str) -> Self { + #[cfg(test)] + MARK_BYTES_SCANNED.with(|n| n.set(n.get() + text.len())); + let mut m = Self::default(); + for c in text.chars() { + match c { + designcraft_doc::ENDNOTE_REF => m.endnotes += 1, + designcraft_doc::OBJECT_MARK => m.objects += 1, + designcraft_doc::XREF_MARK => m.xrefs += 1, + _ => {} + } + } + m + } + + /// The marks before each paragraph of `ranges` (the story's paragraphs, in order), read in + /// one pass over the story. Empty when the story holds no endnotes, anchored objects or + /// cross-references. + fn before_paragraphs(story: &Story, ranges: &[Range]) -> Vec { + if story.endnotes.is_empty() && story.objects.is_empty() && story.xrefs.is_empty() { + return Vec::new(); + } + let mut total = Self::default(); + ranges + .iter() + .map(|r| { + let before = total; + let m = story.text.get(r.clone()).map_or_else(Self::default, Self::of); + total = Self { endnotes: total.endnotes + m.endnotes, objects: total.objects + m.objects, xrefs: total.xrefs + m.xrefs }; + before + }) + .collect() + } } fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { - let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; + let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes, marks } = input; let var_values = if var_story { let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); // A parent page itself (not composed for one of the pages it applies to). @@ -1319,13 +1372,13 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { }; if !story.endnotes.is_empty() { // Endnote reference numbers continue through the document. - let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32); + let first = doc.endnote_start(story.id).saturating_add(u32::try_from(marks.endnotes).unwrap_or(u32::MAX)); for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() { sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32))); } } if !story.objects.is_empty() { - let before = story.text[..prange.start].matches(designcraft_doc::OBJECT_MARK).count(); + let before = marks.objects; for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::OBJECT_MARK).enumerate() { let Some(o) = story.objects.get(before + k) else { continue }; let (w, h) = o.size(); @@ -1338,7 +1391,7 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { } } if !story.xrefs.is_empty() { - sub.xrefs = xref::texts_in(doc, story, prange.clone(), opts.xrefs.as_deref()); + sub.xrefs = xref::texts_after(doc, story, prange.clone(), marks.xrefs, opts.xrefs.as_deref()); } if notes.active() { notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); @@ -3157,6 +3210,8 @@ fn compose_run( let mut list_counter = ListCounter::default(); // Named lists number independently of layout: one pass up front, for the labels. let named_labels = named_list_labels(doc, story, ¶_ranges, &deleted); + // Endnote, anchored-object and cross-reference marks before each paragraph, for their numbers. + let marks_before = MarkCounts::before_paragraphs(story, ¶_ranges); // Border and shading of each paragraph set, drawn once the lines are in place. let mut boxes: Vec> = vec![None; np]; // Keep options: paragraphs are re-laid from a snapshot when a keep is violated, either forced @@ -3316,6 +3371,7 @@ fn compose_run( var_story, var_cache: &mut var_cache, notes: &mut notes, + marks: marks_before.get(pi).copied().unwrap_or_default(), }); #[cfg(test)] reuse::SHAPED.with(|n| n.set(n.get() + 1)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a37729faa..a0eac4061 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8689,6 +8689,73 @@ fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() { const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8(); +/// A story of `paras` paragraphs, each with an inline anchored object at its start, an endnote +/// reference after it and a cross-reference that shows `Ref.` for paragraph `k`. +fn story_with_marks(paras: usize) -> (Document, StoryId) { + let text = vec!["Some words, a note and an object."; paras].join("\n"); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, 20_000.0), ParaAttrs::default()); + let starts: Vec = d.story(sid).unwrap().para_ranges().iter().map(|r| r.start).collect(); + let lid = d.default_layer(); + for (k, &s) in starts.iter().enumerate().rev() { + let it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 6.0, 6.0)), + ); + let story = d.story_mut(sid).unwrap(); + story.insert_xref(s + 19, designcraft_doc::CrossRef { text: Some(format!("Ref{k}.")), ..Default::default() }); + story.insert_endnote(s + 10, "An endnote.", ParaFormat::default()); + story.insert_object(s, designcraft_doc::AnchoredObject::new(it, designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 })); + } + (d, sid) +} + +/// The marks before each paragraph match a count from the story start, for every kind of mark. +#[test] +fn mark_counts_before_each_paragraph_match_a_count_from_the_start() { + let (d, sid) = story_with_marks(5); + let story = d.story(sid).unwrap(); + let ranges = story.para_ranges(); + let counts = MarkCounts::before_paragraphs(story, &ranges); + assert_eq!(counts.len(), ranges.len()); + for (k, (r, m)) in ranges.iter().zip(&counts).enumerate() { + let head = &story.text[..r.start]; + assert_eq!(m.endnotes, head.matches(designcraft_doc::ENDNOTE_REF).count(), "paragraph {k}"); + assert_eq!(m.objects, head.matches(designcraft_doc::OBJECT_MARK).count(), "paragraph {k}"); + assert_eq!(m.xrefs, head.matches(designcraft_doc::XREF_MARK).count(), "paragraph {k}"); + assert_eq!(*m, MarkCounts { endnotes: k, objects: k, xrefs: k }, "paragraph {k}"); + } + // Each paragraph shows its own cross-reference's text, so the composer numbers the + // cross-references from the marks before the paragraph. + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let shown: String = cs.frames[0].lines.iter().flat_map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.rendered_char)).collect(); + let refs: Vec<&str> = shown.match_indices("Ref").map(|(i, _)| &shown[i..i + 5]).collect(); + assert_eq!(refs, ["Ref0.", "Ref1.", "Ref2.", "Ref3.", "Ref4."], "{shown:?}"); + // A story without endnotes, anchored objects or cross-references reads nothing. + let (plain, psid, _) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + let p = plain.story(psid).unwrap(); + assert!(MarkCounts::before_paragraphs(p, &p.para_ranges()).is_empty()); +} + +/// Composing a story with endnotes, anchored objects and cross-references reads its text for their numbers in one +/// pass, so four times the paragraphs read about four times the bytes, not sixteen times. +#[test] +fn composing_reads_marks_once_per_story() { + let scanned = |paras: usize| { + let (d, sid) = story_with_marks(paras); + MARK_BYTES_SCANNED.with(|n| n.set(0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].objects.len(), paras); + (MARK_BYTES_SCANNED.with(std::cell::Cell::get), d.story(sid).unwrap().text.len()) + }; + let (small, small_len) = scanned(40); + let (big, big_len) = scanned(160); + assert!(small > 0); + assert!(big * small_len <= 2 * small * big_len, "40 paragraphs read {small} of {small_len} bytes, 160 read {big} of {big_len}"); +} + #[test] fn anchored_object_wraps_its_own_storys_later_lines() { let text = [LOREM; 4].join(" "); diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 1d72f1ee1..d658eb3d9 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -68,11 +68,26 @@ pub fn xref_text(doc: &Document, x: &xref::CrossRef, index: Option<&XrefIndex>) /// Generated text of the cross-references in `range` of `story`, by byte of their mark. pub fn texts_in(doc: &Document, story: &Story, range: std::ops::Range, index: Option<&XrefIndex>) -> HashMap { + if story.xrefs.is_empty() { + return HashMap::new(); + } + let before = story.text[..range.start].matches(XREF_MARK).count(); + texts_after(doc, story, range, before, index) +} + +/// [`texts_in`] for a caller that already knows `before`, the number of cross-reference marks in +/// `story` before `range`. +pub(crate) fn texts_after( + doc: &Document, + story: &Story, + range: std::ops::Range, + before: usize, + index: Option<&XrefIndex>, +) -> HashMap { let mut out = HashMap::new(); if story.xrefs.is_empty() { return out; } - let before = story.text[..range.start].matches(XREF_MARK).count(); for (k, (i, _)) in story.text[range.clone()].match_indices(XREF_MARK).enumerate() { if let Some(x) = story.xrefs.get(before + k) { out.insert(range.start + i, xref_text(doc, x, index));