compose: index lookups that were quadratic in a paragraph, line or table (#2180)

- Footnote references on a line are deduplicated with a set instead of
  searching the references collected so far.
- Nested styles stream the paragraph up to their delimiter with one character
  of lookahead instead of copying the rest of the paragraph for each style,
  and a delimiter's characters are indexed once per style.
- Shaping resolves which nested/GREP style is on top with one sweep over the
  overlay boundaries instead of scanning every overlay for each stretch.
- Column rules group crossing lines by paragraph through an index.
- Table fragments index each row's top once instead of searching the rows
  for every cell.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-03 06:48:12 -05:00
1 parent 1256decbeb
commit 386b600572
5 files changed
+129 -22

No files matched your search

+10 -3
View File
@@ -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<usize, usize> = 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));
+4 -2
View File
@@ -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<usize> {
let mut v: Vec<usize> = 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);
}
+30 -8
View File
@@ -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<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
})
}
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> 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<char>, c: char, prev: Option<char>, next: Option<char>) -> 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<char>, next: Option<char>) ->
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<usize>, 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<char> = 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();
+77 -8
View File
@@ -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<usize> = 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<bool>) -> 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<usize>, String)]) -> Vec<(usize, Option<usize>)> {
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<usize> = 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<usize>, 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<usize>, 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);
}
}
+8 -1
View File
@@ -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<usize, f64> = 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();