mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-05 10:10:35 +00:00
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:
1 parent
1256decbeb
commit
386b600572
5 files changed
+129
-22
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user