From 0f792e1fe9375183881cc5b64d446d9821757543 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:32:12 -0800 Subject: [PATCH 1/2] Compose: vertical justification moves an object kept within its column with its line (#2720) * Text wrap: an object kept within its column moves with its line Vertical justification treated an object positioned from its anchor and kept within its column as fixed, so its wrap stopped Center, Bottom and Justify where the object started, although the object moves down with its anchor's line until it reaches the column's bottom. A line now moves down at most the room above the object plus the distance the object can move before the column stops it. The story's object-mark positions are now collected once per composition instead of once per frame that holds an anchored object. * Compose: Justify keeps the room under an object held at the column's top An object kept within its column and held at the column's top moves only after its anchor's row has moved down far enough to bring it off the top. Justify took that distance off a later line's room even when the anchor's row moved less, so with the anchor in the first row, which stays in place, the later line stopped that distance short of the object's wrap. A line now moves its room plus how far the anchor's row moves past that distance. --- crates/compose/src/lib.rs | 102 +++++++++++++++++++++++++----------- crates/compose/src/tests.rs | 86 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 30 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 333959626..ab2a9ede7 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3626,6 +3626,8 @@ struct FinishInput<'a> { fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let FinishInput { doc, story, frames, cols, notes, out, boxes, span_paras, split_paras, grid_paras, styles_tab } = input; notes.finish(doc, story, frames, cols, out); + // The story byte of each anchored object's mark, found once for every frame that holds one. + let mut marks: Option> = None; // Ranges, content heights and vertical justification. for (fi, ft) in out.frames.iter_mut().enumerate() { let f = &frames[fi]; @@ -3641,9 +3643,12 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - let anchors: Vec = - if ft.objects.is_empty() { Vec::new() } else { story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect() }; - vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, &anchors); + let anchors: &[usize] = if ft.objects.is_empty() { + &[] + } else { + marks.get_or_insert_with(|| story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect()) + }; + vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, anchors); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -6077,18 +6082,19 @@ fn vertical_justify( None => true, }; // Each row moves no further than a text wrap under it allows. An object that moves with its - // anchor's row (Justify) limits how much further than that row a later row moves. + // anchor's row (Justify) lets a later row move its room plus how far the object moves. let mut row_of = vec![None; ft.lines.len()]; for (&i, &k) in idx.iter().zip(&rows) { row_of[i] = Some(k); } - let follow: Vec<(usize, usize, f64)> = follow + let follow: Vec<(usize, usize, f64, f64)> = follow .iter() - .filter_map(|&(i, a, room)| Some((row_of.get(i).copied().flatten()?, row_of.get(a).copied().flatten()?, room.max(0.0)))) + .filter_map(|&(i, a, room, pin)| Some((row_of.get(i).copied().flatten()?, row_of.get(a).copied().flatten()?, room.max(0.0), pin))) .collect(); + // The object moves only once its anchor's row has moved `pin`. let clear = |shift: &[f64]| { idx.iter().zip(&rows).all(|(&i, &k)| shift[k] <= rooms[i].max(0.0) + 1e-9) - && follow.iter().all(|&(k, m, room)| shift[k] - shift[m] <= room + 1e-9) + && follow.iter().all(|&(k, m, room, pin)| shift[k] <= room + (shift[m] - pin).max(0.0) + 1e-9) }; // The lowest lines are tested first: they are the ones a narrowing outline stops. let fits = |shift: &[f64]| { @@ -6206,13 +6212,16 @@ fn vertical_justify( /// box. Beside or under a contour, the line moves down only as far as its band stays clear of the /// contour on its way down (to 0.01 pt). Only wraps that reach into a line's column (or the /// columns it spans) count, as when the lines are set. An anchored object that moves with the -/// lines being justified (inline, above the line, or positioned from its anchor and not kept -/// within its column) is skipped, except under Justify, where it moves with the row of its -/// anchor's line: its wrap is taken where the object sits before the lines move, and its rooms go -/// in the second list as (line, anchor's line, room), so a line moves down at most that much -/// further than the anchor's row. Justify keeps the first row in place, so its room stays -/// infinite. `anchors` holds the story byte of each anchored object's mark, by object index. -fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[usize]) -> (Vec, Vec<(usize, usize, f64)>) { +/// lines being justified (inline, above the line, or positioned from its anchor) is skipped, +/// except under Justify, where it moves with the row of its anchor's line: its wrap is taken where +/// the object sits before the lines move, and its rooms go in the second list as (line, anchor's +/// line, room, pin), so a line moves down at most the room plus how far the anchor's row moves +/// past `pin`. An object positioned from its anchor and kept within its column moves with its +/// anchor's row until it stops at the column's bottom, so in every mode a line moves down at most +/// the room under it plus the distance the object can move. Held at the column's top, the object +/// moves only after its anchor's row has moved `pin` (0 for the other objects), and until then a +/// line keeps the whole room. Justify keeps the first row in place, so its room stays infinite. `anchors` holds the story byte of each anchored object's mark, by object index. +fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[usize]) -> (Vec, Vec<(usize, usize, f64, f64)>) { const EPS: f64 = 1e-6; // One-sided wraps also block the column on the other side of the object: the x-range wrap // `e` keeps text out of in column `col`, from the object's x-range `a..b` there (None when that @@ -6247,23 +6256,43 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u } } // An own-story anchored object whose anchor is on one of these lines and whose position - // follows its line moves with the text: the index in `ft.lines` of its anchor's line, and how - // much lower that line was when the wrap was taken (an earlier pass placed the object after - // moving the lines). - let moves_with_text = |e: &Exclusion| -> Option<(usize, f64)> { + // follows its line moves with the text: the index in `ft.lines` of its anchor's line, how + // much lower the object was when the wrap was taken (an earlier pass placed the object after + // moving the lines), and, for one kept within its column, how far its line moves before the + // object leaves the column's top and how far the object then moves before it stops at the + // column's bottom (none and infinite for the others). + let moves_with_text = |e: &Exclusion| -> Option<(usize, f64, f64, f64)> { let o = *by_anchor.get(&e.after?)?; - let follows = match &o.object.position { - designcraft_doc::AnchorPosition::Inline { .. } | designcraft_doc::AnchorPosition::AboveLine { .. } => true, - // Kept within the column, it can stop at the column's edge instead of following. - designcraft_doc::AnchorPosition::Custom { y_relative, keep_within_column, .. } => { - *y_relative == designcraft_doc::anchored::AnchorRelative::Anchor && !keep_within_column + let l = ft.lines.get(o.line)?; + let now = l.baseline; + let dy = e.anchor_baseline.map_or(0.0, |b| b - now); + match &o.object.position { + designcraft_doc::AnchorPosition::Inline { .. } | designcraft_doc::AnchorPosition::AboveLine { .. } => { + Some((o.line, dy, 0.0, f64::INFINITY)) } - }; - let now = ft.lines.get(o.line).map_or(0.0, |l| l.baseline); - follows.then(|| (o.line, e.anchor_baseline.map_or(0.0, |b| b - now))) + designcraft_doc::AnchorPosition::Custom { y_relative, keep_within_column, y_offset, object_point, .. } + if *y_relative == designcraft_doc::anchored::AnchorRelative::Anchor => + { + if !keep_within_column { + return Some((o.line, dy, 0.0, f64::INFINITY)); + } + // Placed as the lines' final positions place it: from its anchor's baseline, + // held between the column's top and bottom. + let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); + let h = o.size.1; + let ofy = [0.0, 0.5, 1.0][(*object_point / 3).min(2) as usize]; + let (lo, hi) = (col.y0, (col.y1 - h).max(col.y0)); + let place = |b: f64| b + y_offset - h * ofy; + let raw = place(now); + let y = raw.clamp(lo, hi); + let then = e.anchor_baseline.map_or(y, |b| place(b).clamp(lo, hi)); + Some((o.line, then - y, (lo - raw).max(0.0), (hi - y).max(0.0))) + } + designcraft_doc::AnchorPosition::Custom { .. } => None, + } }; // Each wrap's object is looked up once, not for every line. - let moving: Vec> = f.exclusions.iter().map(moves_with_text).collect(); + let moving: Vec> = f.exclusions.iter().map(moves_with_text).collect(); // Justify keeps the first row (its segments and a table on them) in place, so wraps under it // set no limit. let first = ft.lines.iter().find(|l| in_col(l.column)).map(|l| l.baseline); @@ -6290,13 +6319,15 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u let col = Rect { x0: col.x0.min(x0), x1: col.x1.max(x1), ..col }; let meets = |(a, b): (f64, f64)| a < x1 - EPS && b > x0 + EPS; for (e, &anchor) in f.exclusions.iter().zip(&moving).filter(|(e, _)| e.wraps_line(start)) { - if anchor.is_some() && !justify { + // Center and Bottom move every row as far: only an object kept within its column, which + // can stop at the column's edges, holds the lines back. + if anchor.is_some_and(|(_, _, _, slack)| slack == f64::INFINITY) && !justify { continue; } // The wrap where its object sits before the lines move (moved up by `dy`), and how // far it lets this line move down (relative to its anchor's row for an object that // moves with the text). - let dy = anchor.map_or(0.0, |(_, dy)| dy); + let dy = anchor.map_or(0.0, |(_, dy, _, _)| dy); let cap = if anchor.is_some() { f64::INFINITY } else { room }; let mut room_e = f64::INFINITY; let r = Rect { y0: e.rect.y0 - dy, y1: e.rect.y1 - dy, ..e.rect }; @@ -6335,7 +6366,18 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u } } match anchor { - Some((a, _)) if room_e.is_finite() => follow.push((line, a, room_e)), + // An object kept within its column moves once its line has moved `pin` (to leave + // the column's top) and stops after moving `slack` (at the column's bottom). + Some((a, _, pin, slack)) if room_e.is_finite() => { + if room_e >= pin { + if justify { + follow.push((line, a, room_e, pin)); + } + room = room.min(room_e + slack); + } else { + room = room.min(room_e); + } + } Some(_) => {} None => room = room.min(room_e), } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ccc71578d..a37729faa 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -890,6 +890,92 @@ fn vertical_justification_stops_at_an_anchored_object_kept_within_its_column() { ); } +/// An object positioned from its anchor and kept within its column moves with its anchor's line +/// until it reaches the column's bottom, so Bottom moves the lines above it down that far, not +/// only as far as where the object starts. +#[test] +fn vertical_justification_moves_an_object_kept_within_its_column_until_it_stops() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + for vj in [Bottom, Center, Justify] { + let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + // Hung 40 pt under the first baseline, under the second line, and far above the column's + // bottom. + anchor_box(&mut d, sid, 1, 0.0, 40.0, true, WrapMode::BoundingBox); + if let designcraft_doc::AnchorPosition::Custom { keep_within_column, .. } = + &mut std::sync::Arc::make_mut(&mut d.story_mut(sid).unwrap().objects[0]).position + { + *keep_within_column = true; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let o = &cs.frames[0].objects[0]; + let (top, bottom) = (o.origin.y, o.origin.y + 60.0); + let l = lines[1]; + assert!(l.baseline <= top + 1e-6, "{vj:?}: the second line at {} meets the object at {top}..{bottom}", l.baseline); + match vj { + // The object stops at the column's bottom and the second line comes down onto it. + Bottom => { + assert!((bottom - 400.0).abs() < 1e-6, "{vj:?}: the object stopped at {top}..{bottom}, above the column's bottom"); + assert!(l.baseline > top - 0.01, "{vj:?}: the second line stopped at {}, above the object at {top}", l.baseline); + } + // Justify keeps the first line, and the object anchored in it, in place; the second + // line comes down onto the object. + Justify => { + assert!((top - lines[0].baseline - 40.0).abs() < 1e-6, "{vj:?}: the object moved to {top}..{bottom}"); + assert!(l.baseline > top - 0.01, "{vj:?}: the second line stopped at {}, above the object at {top}", l.baseline); + } + // Center moves the lines half of the room under them: the object moves with them. + _ => assert!(bottom < 400.0 - 1e-6 && l.baseline > 150.0, "{vj:?}: second line at {}, object at {top}..{bottom}", l.baseline), + } + } +} + +/// An object anchored in the first line, positioned from its anchor and kept within its column, +/// is held at the column's top. Justify keeps the first line in place, so the object stays there, +/// and the second line comes down onto the wider lower part of its contour. +#[test] +fn vertical_justify_keeps_the_room_under_an_object_held_at_the_columns_top() { + use designcraft_doc::anchored::AnchorRelative as R; + let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Justify; + // Beside the lines at x 250..300 down to y 100, then x 100..300 down to y 160. + let pts = [ + Point::new(150.0, 0.0), + Point::new(200.0, 0.0), + Point::new(200.0, 160.0), + Point::new(0.0, 160.0), + Point::new(0.0, 100.0), + Point::new(150.0, 100.0), + ]; + let lid = d.default_layer(); + let path = designcraft_geom::path::PathData::single(designcraft_geom::path::SubPath::polyline(&pts, true)); + let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, path); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::BothSides; + // 50 pt above the first baseline, so the column's top holds it. + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: 100.0, + y_offset: -50.0, + object_point: 0, + ref_point: 0, + keep_within_column: true, + spine_relative: false, + }; + d.story_mut(sid).unwrap().insert_object(1, designcraft_doc::AnchoredObject::new(it, position)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 2); + let top = cs.frames[0].objects[0].origin.y; + assert!(lines[0].baseline < 50.0 && top.abs() < 1e-6, "the object is held at the column's top: {top}, {}", lines[0].baseline); + let wide = top + 100.0; + let b = lines[1].baseline; + assert!(b <= wide + 1e-6, "Justify moved the second line to {b}, into the contour's wider part at {wide}"); + assert!(b > wide - 0.01, "Justify stopped the second line at {b}, short of the contour's wider part at {wide}"); +} + /// Justify keeps the first line in place and moves later lines further, so an object anchored in /// the first line and hung under the last one still stops the lines after its anchor. #[test] From 437c53251152b651f2352edb0a4f803000bda24d Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:42:10 -0800 Subject: [PATCH 2/2] 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));