diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f110c0ecc..e8e08dba6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3325,15 +3325,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio continue; } // Vertical bands of the paragraphs whose lines cross this gutter. + // Grouped by paragraph through an index, in first-seen order: searching the bands for + // each line was quadratic in the number of paragraphs. let mut spans: Vec<(usize, f64, f64)> = Vec::new(); + let mut band_of: HashMap = HashMap::new(); for l in lines.iter().filter(|l| l.x0 < cx && l.x1 > cx) { let (t, z) = (l.baseline - l.ascent, l.baseline + l.descent); - match spans.iter_mut().find(|s| s.0 == l.para) { - Some(s) => { + match band_of.get(&l.para) { + Some(&k) => { + let s = &mut spans[k]; s.1 = s.1.min(t); s.2 = s.2.max(z); } - None => spans.push((l.para, t, z)), + None => { + band_of.insert(l.para, spans.len()); + spans.push((l.para, t, z)); + } } } spans.sort_by(|p, q| p.1.total_cmp(&q.1)); diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 1e1c852a5..67f922637 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -14,7 +14,7 @@ //! With Span Footnotes Across Columns the notes of a frame share one area across all its columns, //! composed at the frame's text width: every column of the frame makes room for it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::sync::Arc; @@ -146,11 +146,13 @@ impl Notes { } /// Footnote indices referenced by a line's glyphs. + /// In the order they first appear; a set keeps a line with many references linear. pub fn refs_in(&self, glyphs: &[Glyph]) -> Vec { let mut v: Vec = Vec::new(); + let mut seen = HashSet::new(); for g in glyphs.iter().filter(|g| g.ch == FOOTNOTE_REF && g.len > 0) { if let Ok(k) = self.refs.binary_search(&g.byte) - && !v.contains(&k) + && seen.insert(k) { v.push(k); } diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index b09ef908e..6cd3df72f 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -2,7 +2,7 @@ //! through or up to a delimiter) and GREP styles (every match of a pattern). use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; @@ -47,7 +47,9 @@ fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { }) } -fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { +/// Whether `c` ends one unit of `u`. `delims` is the character set of a [`NestedUntil::Chars`] +/// delimiter, indexed once per nested style. +fn is_event(u: &NestedUntil, delims: &HashSet, c: char, prev: Option, next: Option) -> bool { match u { NestedUntil::Sentences => matches!(c, '.' | '!' | '?') && next.is_none_or(char::is_whitespace), NestedUntil::Words => c.is_whitespace() && prev.is_some_and(|p| !p.is_whitespace()), @@ -60,7 +62,7 @@ fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> NestedUntil::EnSpace => c == '\u{2002}', // Counted by position, not by character (see `overlays`). NestedUntil::Dropcap => false, - NestedUntil::Chars(s) => s.contains(c), + NestedUntil::Chars(_) => delims.contains(&c), } } @@ -81,18 +83,25 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], // Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing. stop = if ns.through { drop_end.max(pos).min(end) } else { pos }; } else { + // Streamed up to the delimiter, with one character of lookahead: copying the rest of + // the paragraph for every nested style multiplied its length by the number of styles. + let delims: HashSet = match &ns.until { + NestedUntil::Chars(s) => s.chars().collect(), + _ => HashSet::new(), + }; let mut count = 0; - let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect(); - for (k, &(i, c)) in chars.iter().enumerate() { - let prev = if k > 0 { Some(chars[k - 1].1) } else { None }; - let next = chars.get(k + 1).map(|x| x.1); - if is_event(&ns.until, c, prev, next) { + let mut prev = None; + let mut chars = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).peekable(); + while let Some((i, c)) = chars.next() { + let next = chars.peek().map(|x| x.1); + if is_event(&ns.until, &delims, c, prev, next) { count += 1; if count >= ns.count.max(1) { stop = if ns.through { i + c.len_utf8() } else { i }; break; } } + prev = Some(c); } } if stop > pos && !ns.style.is_empty() { @@ -161,6 +170,19 @@ mod tests { assert!(has_last, "the most recently used pattern is still cached"); } + /// Nested styles that stop where they start (up to a delimiter the paragraph starts with) + /// read one character each, not the rest of the paragraph. + #[test] + fn nested_styles_stopping_at_once_do_not_rescan_the_paragraph() { + let text = format!("x{}\n", "y".repeat(200_000)); + let delim: String = ('a'..='z').chain('A'..='Z').filter(|c| *c != 'y').collect(); + let rule = NestedStyle { style: "S".into(), through: false, count: 1, until: NestedUntil::Chars(delim) }; + let nested = vec![rule; 5000]; + let t = std::time::Instant::now(); + assert!(overlays(&text, 0..text.len(), &nested, &[], 0).is_empty(), "each style styles nothing"); + assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed()); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 078848672..4ce7247d5 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -385,6 +385,7 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve overlays.push((range.start..drop_cap.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. + let bands = overlay_bands(&overlays); let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); for (rr, fmt) in story.runs() { let a = rr.start.max(range.start); @@ -396,14 +397,16 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve segments.push((a, b, None, fmt)); continue; } - let mut cuts: Vec = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect(); - cuts.push(a); - cuts.push(b); - cuts.sort_unstable(); - cuts.dedup(); - for w in cuts.windows(2) { - let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str()); - segments.push((w[0], w[1], top, fmt)); + // The band holding `a`, then each following band up to `b`. Bands start at 0, so one + // holds `a`, and each ends where the next starts (the last one at `b`). + let mut k = bands.partition_point(|(at, _)| *at <= a).saturating_sub(1); + let mut at = a; + while at < b { + let to = bands.get(k + 1).map_or(b, |(next, _)| (*next).min(b)); + let top = bands.get(k).and_then(|(_, top)| *top).map(|i| overlays[i].1.as_str()); + segments.push((at, to, top, fmt)); + at = to; + k += 1; } } // Resolve every segment, then shape runs of segments that differ only in how they are @@ -1426,3 +1429,69 @@ fn digit_mode(p: &designcraft_doc::CharProps, arabic_context: Option) -> d _ => Digits::Native, } } + +/// The stretches between `overlays`' boundaries, as `(start, overlay on top)` sorted by start: +/// a stretch runs to the next one's start, and the one on top is the last overlay covering it +/// (later ones win). One sweep over the boundaries; picking the top overlay by scanning them all +/// for each stretch was quadratic when a GREP style matched every character. +fn overlay_bands(overlays: &[(std::ops::Range, String)]) -> Vec<(usize, Option)> { + let mut starts: Vec<(usize, usize)> = Vec::new(); + let mut ends: Vec<(usize, usize)> = Vec::new(); + for (i, (r, _)) in overlays.iter().enumerate().filter(|(_, (r, _))| r.start < r.end) { + starts.push((r.start, i)); + ends.push((r.end, i)); + } + starts.sort_unstable(); + ends.sort_unstable(); + let mut cuts: Vec = starts.iter().chain(&ends).map(|x| x.0).collect(); + cuts.push(0); + cuts.sort_unstable(); + cuts.dedup(); + let mut active = std::collections::BTreeSet::new(); + let (mut s, mut e) = (0, 0); + let mut out = Vec::with_capacity(cuts.len()); + for at in cuts { + while e < ends.len() && ends[e].0 <= at { + active.remove(&ends[e].1); + e += 1; + } + while s < starts.len() && starts[s].0 <= at { + active.insert(starts[s].1); + s += 1; + } + out.push((at, active.last().copied())); + } + out +} + +#[cfg(test)] +mod tests { + use super::overlay_bands; + + /// The band sweep picks the same top overlay as checking every overlay at each boundary. + #[test] + fn overlay_bands_match_the_last_covering_overlay() { + let overlays: Vec<(std::ops::Range, String)> = [(2..9, "a"), (0..4, "b"), (4..4, "empty"), (3..6, "c"), (8..12, "d"), (5..7, "e")] + .into_iter() + .map(|(r, s)| (r, s.to_string())) + .collect(); + let bands = overlay_bands(&overlays); + for at in 0..14 { + let k = bands.partition_point(|(start, _)| *start <= at) - 1; + let want = overlays.iter().rposition(|(r, _)| r.start <= at && at < r.end); + assert_eq!(bands[k].1, want, "at {at}"); + } + } + + /// One overlay per character (a GREP style matching every character) sweeps in one pass. + #[test] + fn many_overlays_are_banded_quickly() { + let n = 200_000; + let overlays: Vec<(std::ops::Range, String)> = (0..n).map(|i| (i..i + 1, "s".to_string())).collect(); + let t = std::time::Instant::now(); + let bands = overlay_bands(&overlays); + assert_eq!(bands.len(), n + 1); + assert_eq!(bands[n / 2], (n / 2, Some(n / 2))); + assert!(t.elapsed().as_secs() < 5); + } +} diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index a124b1fce..5811887ae 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -5,6 +5,7 @@ //! cell (or its fixed height); rows that don't fit continue in the next column / frame, repeating //! header (and footer) rows. Rows tied together by row spans never split. +use std::collections::HashMap; use std::sync::Arc; use designcraft_doc::{Align, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Table, TextFrameOptions, VerticalJustification}; @@ -404,7 +405,13 @@ fn emit( out: &mut ComposedStory, ) { let nc = table.ncols(); - let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1); + // Each row's top (its first occurrence in the fragment), indexed once: looking it up per cell + // by scanning the rows was quadratic in a fragment's row count. + let mut row_top: HashMap = HashMap::with_capacity(rows.len()); + for &(r, y, _) in rows { + row_top.entry(r).or_insert(y); + } + let ry = |r: usize| row_top.get(&r).copied(); let header = table.header_rows(); let body = table.body_rows(); let nbody = body.len();