From 6d0556e60f5f1eafc5ebf6d54f89c05777609433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:31:37 -0800 Subject: [PATCH 1/2] Type: a threaded story measures each frame with the paragraphs it needs Distributing a story through threaded frames measured every frame but the last with the whole rest of the story, and copied that rest several times per frame, so a long story through many frames cost frames times story length. - The rest of the story is a borrowed view of the given runs (run index, byte offset, length). Each frame's share is copied once; the slices, styles of empty shares and the run left once the story is used up are the same as before. - Area type without Shrink Text to Fit whose lines align to the top is first measured with the text through the end of the paragraph that reaches about twice the previous frame's share (at least 512 bytes), doubling while the story goes on. The share is taken when its text stops fitting before the last paragraph break measured; layout places lines in order, stops at the first that does not fit and shapes each paragraph on its own text. Type on a path, Shrink Text to Fit, other vertical alignments and a share ending in the story's last paragraph are measured with the whole rest. Tests compare every slice with the earlier algorithm, kept in the tests, for many kinds of frames, styles and paragraphs and 12 seeded stories. A story of 1,500 paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes while measuring and is now laid out over 21,035. --- crates/text/src/thread.rs | 243 +++++++++++++++++++++++++--- crates/text/src/thread/tests.rs | 272 +++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 491 insertions(+), 25 deletions(-) diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index cfdd41cc78..0abd172bff 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -5,9 +5,9 @@ //! with [`distribute`]. use vectorcraft_doc::text::ListCounts; -use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun}; -use crate::edit::{normalize, runs_len, slice_runs, style_at}; +use crate::edit::{floor_char, normalize, runs_len}; use crate::{FontDb, layout}; /// The story: every frame's runs joined in thread order. @@ -57,21 +57,23 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec usize { - let mut probe = frame.clone(); - probe.runs = runs.to_vec(); - let lay = layout(db, &probe); - let text: String = runs.iter().map(|r| r.text.as_str()).collect(); +/// overflows as any character does, it starts the next frame), and for area type that overflows, +/// where its first line that does not fit starts. +fn fit(db: &FontDb, probe: &TextObject) -> (usize, Option) { + #[cfg(test)] + FIT_BYTES.set(FIT_BYTES.get() + runs_len(&probe.runs)); + let lay = layout(db, probe); + let text: String = probe.runs.iter().map(|r| r.text.as_str()).collect(); if !lay.overflow { - return text.len(); + return (text.len(), None); } let mut end = lay.lines.last().map_or(0, |l| l.end); if lay.on_path { - return end; + return (end, None); } + let unplaced = end; let bytes = text.as_bytes(); while end < bytes.len() && bytes[end] == b' ' { end += 1; @@ -79,7 +81,179 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { if end < bytes.len() && bytes[end] == b'\n' { end += 1; } - end + (end, Some(unplaced)) +} + +#[cfg(test)] +thread_local! { + /// The bytes of text [`fit`] has laid out on this thread ([`fit_bytes`]). + static FIT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of text [`fit`] has laid out on this thread. +#[cfg(test)] +fn fit_bytes() -> usize { + FIT_BYTES.get() +} + +/// The fewest bytes of story a frame is measured with before the measured text grows. +const MIN_MEASURE: usize = 512; + +/// What is left of a story while [`distribute_continuing_levels`] hands it out, `len` bytes +/// borrowed from the story's runs: the runs from `run` on, the first of them without its first +/// `skip` bytes. While `raw` they are all the runs given (`run` and `skip` are 0), empty runs +/// included. Once a frame has taken a share they are the story after it as +/// [`crate::edit::slice_runs`] cuts it: no empty runs, and a run cut after its first character +/// without its glyph choice. +#[derive(Clone, Copy)] +struct Rest<'a> { + runs: &'a [TextRun], + run: usize, + skip: usize, + raw: bool, + len: usize, +} + +impl<'a> Rest<'a> { + /// The runs' pieces in order: each piece's text, its run and whether the run was cut before it. + fn pieces(self) -> impl Iterator { + self.runs.get(self.run..).unwrap_or_default().iter().enumerate().filter_map(move |(k, r)| { + let at = if k == 0 { self.skip } else { 0 }; + let text = r.text.get(at..)?; + (self.raw || !text.is_empty()).then_some((text, r, at > 0)) + }) + } + + /// A piece's style: its run's, without the glyph choice when the run was cut before it. + fn style(r: &TextRun, cut: bool) -> CharStyle { + let mut style = r.style.clone(); + if cut { + style.glyph_alternate = None; + } + style + } + + /// [`crate::edit::slice_runs`] of the rest's runs. + fn slice(self, start: usize, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + let (rs, re) = (off, off + text.len()); + if rs >= end { + break; + } + let (a, b) = (start.max(rs), end.min(re)); + if a < b { + let from = floor_char(text, a - rs); + let t = &text[from..floor_char(text, b - rs)]; + if !t.is_empty() { + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut || from > 0), inline: r.inline.clone() }); + } + } + off = re; + } + out + } + + /// The rest's runs before byte `end`, which follows a paragraph break, as a frame is measured + /// with them: every run that starts before it (an empty one too, while the runs are the ones + /// given), the last one cut there. + fn before(self, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + if off >= end { + break; + } + let t = text.get(..end - off).unwrap_or(text); + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut), inline: r.inline.clone() }); + off += text.len(); + } + out + } + + /// [`crate::edit::style_at`] of the rest's runs. + fn style_at(self, byte: usize) -> CharStyle { + let mut off = 0; + let mut last = None; + let mut first = None; + for (text, r, cut) in self.pieces() { + first = first.or(Some((r, cut))); + let end = off + text.len(); + if byte < end || (byte == end && byte == off) { + return Self::style(r, cut); + } + if !text.is_empty() { + last = Some((r, cut)); + if byte == end { + return Self::style(r, cut); + } + } + off = end; + } + last.or(first).map(|(r, cut)| Self::style(r, cut)).unwrap_or_default() + } + + /// The end of the first paragraph break at or after byte `from`, or None when the rest has no + /// break there. + fn break_after(self, from: usize) -> Option { + let mut off = 0; + for (text, _, _) in self.pieces() { + let at = from.saturating_sub(off); + if let Some(i) = text.as_bytes().get(at..).and_then(|b| b.iter().position(|&b| b == b'\n')) { + return Some(off + at + i + 1); + } + off += text.len(); + } + None + } + + /// All of the rest's runs. + fn all(self) -> Vec { + if self.raw { self.runs.to_vec() } else { self.slice(0, self.len) } + } + + /// The rest after its first `n` bytes, as a slice of the story: the run that byte falls in + /// cut there (back to a character boundary, as [`crate::edit::slice_runs`] cuts it). + fn after(self, n: usize) -> Self { + let mut run = self.run; + let mut at = self.skip + n; + while let Some(r) = self.runs.get(run) + && at >= r.text.len() + && run + 1 < self.runs.len() + { + at -= r.text.len(); + run += 1; + } + let skip = self.runs.get(run).map_or(0, |r| floor_char(&r.text, at)); + let cut_back = at - skip; + Self { runs: self.runs, run, skip, raw: false, len: self.len + cut_back - n } + } +} + +/// How many bytes of `rest` fit in `frame`, measured as `probe` makes the frame for some runs (with +/// their paragraph styles, the list counts and the continuation mark). +/// +/// Area type without Shrink Text to Fit whose lines align to the top is first measured with the +/// whole paragraphs of the rest that end at or after byte `want`, and then with twice as many bytes +/// each time, while the rest goes on past them. The layout places lines in order and stops at the +/// first line that does not fit, and each paragraph is shaped and broken on its own text. So when +/// that line starts before the last paragraph break measured, the frame holds the same bytes with +/// the whole rest. Other type, and a rest that ends first, is measured with the whole rest. +fn fit_rest(db: &FontDb, probe: impl Fn(Vec) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize) -> usize { + let by_paragraph = matches!(frame.kind, TextKind::Area { .. }) + && frame.area.fit.min_scale().is_none() + && frame.area.vertical_align == vectorcraft_doc::VerticalAlign::Top; + if by_paragraph { + while let Some(end) = rest.break_after(want.saturating_sub(1)).filter(|&end| end < rest.len) { + let (n, unplaced) = fit(db, &probe(rest.before(end))); + if unplaced.is_some_and(|u| u + 1 < end) { + return n; + } + want = end.saturating_mul(2); + } + } + fit(db, &probe(rest.all())).0 } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the @@ -175,31 +349,52 @@ pub fn distribute_continuing_levels( continued: bool, mut counts: ListCounts, ) -> Vec { - let mut rest = runs.to_vec(); + let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) }; + // Once the story is used up, what is left is one empty run with the style there. + let mut used_up: Option = None; let mut out = Vec::with_capacity(frames.len()); let mut next_para = 0; let mut continues = continued; + let mut want = MIN_MEASURE; for (i, f) in frames.iter().enumerate() { let first_para = next_para; - let len = runs_len(&rest); + let empty_story: [TextRun; 1]; + let rest = match used_up.clone() { + Some(empty) => { + empty_story = [empty]; + Rest { runs: &empty_story, run: 0, skip: 0, raw: true, len: 0 } + } + None => story_rest, + }; + let len = rest.len; let own = [f.para.clone()]; let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; let n = if i + 1 == frames.len() { len } else { - let mut probe = (*f).clone(); - probe.runs = rest.clone(); - probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.set_list_counts(counts); - probe.first_para_continues = continues; - fit(db, &probe, &rest) + let probe = |runs: Vec| { + let mut probe = (*f).clone(); + let styles = slice_paras(paras, first_para, &runs); + probe.runs = runs; + probe.set_paragraph_styles(styles); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + probe + }; + fit_rest(db, probe, f, rest, want) }; - let mut head = slice_runs(&rest, 0, n); + want = n.saturating_mul(2).max(MIN_MEASURE); + let mut head = rest.slice(0, n); if head.is_empty() { - head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + head.push(TextRun { text: String::new(), style: rest.style_at(n), inline: None }); + } + if used_up.is_none() { + if n >= len { + used_up = Some(TextRun { text: String::new(), style: rest.style_at(len), inline: None }); + } else { + story_rest = story_rest.after(n); + } } - let tail = slice_runs(&rest, n, len); - rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); let next_counts = count_list_paragraphs(&head, &head_paras, counts); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index c3f05cef42..5ae49442fb 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -1,5 +1,6 @@ use super::*; -use vectorcraft_doc::{CharStyle, Justify, TextKind}; +use crate::edit::{slice_runs, style_at}; +use vectorcraft_doc::{Justify, TextKind}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; fn frame(x: f64) -> TextObject { @@ -412,6 +413,275 @@ fn list_levels_count_on_through_the_frames() { assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); } +/// The shares the frames would take if each one were measured with the whole rest of the story: +/// what [`distribute_continuing_levels`] gives, measuring each frame with less of it. +fn whole_rest_shares( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, +) -> Vec { + let fit_whole = |frame: &TextObject, runs: &[TextRun]| { + let lay = layout(db, frame); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + if !lay.overflow { + return text.len(); + } + let mut end = lay.lines.last().map_or(0, |l| l.end); + if lay.on_path { + return end; + } + let bytes = text.as_bytes(); + while end < bytes.len() && bytes[end] == b' ' { + end += 1; + } + if end < bytes.len() && bytes[end] == b'\n' { + end += 1; + } + end + }; + let mut rest = runs.to_vec(); + let mut out = Vec::with_capacity(frames.len()); + let mut next_para = 0; + let mut continues = continued; + for (i, f) in frames.iter().enumerate() { + let first_para = next_para; + let len = runs_len(&rest); + let own = [f.para.clone()]; + let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; + let n = if i + 1 == frames.len() { + len + } else { + let mut probe = (*f).clone(); + probe.runs = rest.clone(); + probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + fit_whole(&probe, &rest) + }; + let mut head = slice_runs(&rest, 0, n); + if head.is_empty() { + head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + } + let tail = slice_runs(&rest, n, len); + rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; + let head_paras = slice_paras(paras, first_para, &head); + next_para += head_paras.len().saturating_sub(1); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); + let starts_continued = continues; + continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; + } + out +} + +/// `frames` share `runs` as they would if each frame were measured with the whole rest, from the +/// story's start and continuing from text before it; returns how many frames took text. +fn same_as_whole_rest(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], case: &str) -> usize { + let got = distribute(db, frames, runs, paras); + assert_eq!(got, whole_rest_shares(db, frames, runs, paras, false, ListCounts::default()), "{case}"); + let counts = ListCounts::from_parts(3, &[2, 1]); + let continuing = distribute_continuing_levels(db, frames, runs, paras, true, counts); + assert_eq!(continuing, whole_rest_shares(db, frames, runs, paras, true, counts), "{case}, continuing"); + got.iter().filter(|s| runs_len(&s.runs) > 0).count() +} + +/// An area frame `w` by `h` at `x`. +fn sized(x: f64, w: f64, h: f64) -> TextObject { + let mut t = frame(x); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + w, h)) }; + t +} + +/// Numbered `n` short paragraphs. +fn paragraphs(n: usize) -> String { + (1..=n).map(|i| format!("Item {i}: the quick brown fox jumps.")).collect::>().join("\n") +} + +/// Each frame takes the same share of a long story when it is measured with only the paragraphs +/// it needs as when it is measured with the whole rest of the story, for every kind of frame, +/// text and paragraph style that changes how much a frame holds. +#[test] +fn frames_take_the_shares_the_whole_rest_gives_them() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let plain = CharStyle::default(); + let big = CharStyle { size: 20.0, ..Default::default() }; + let small = CharStyle { size: 7.0, ..Default::default() }; + let story = |text: &str| vec![TextRun::new(text, plain.clone())]; + let frames: Vec = (0..6).map(|k| frame(200.0 * k as f64)).collect(); + fn refs(v: &[TextObject]) -> Vec<&TextObject> { + v.iter().collect() + } + let mut took = 0; + + // Many short paragraphs, every one of its own style. + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &[], "short paragraphs"); + let spaced: Vec = (0..120).map(|i| ParaStyle { space_before: (i % 4) as f64 * 3.0, ..Default::default() }).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &spaced, "spaced paragraphs"); + + // Frames of different sizes, one with no height and one too narrow for a word. + let uneven = vec![ + sized(0.0, 120.0, 30.0), + sized(200.0, 120.0, 0.0), + sized(400.0, 200.0, 140.0), + sized(700.0, 4.0, 200.0), + sized(800.0, 60.0, 90.0), + frame(1000.0), + ]; + took += same_as_whole_rest(db, &refs(&uneven), &story(¶graphs(120)), &[], "uneven frames"); + + // A word longer than a line, and runs of spaces and empty paragraphs where frames end. + let long_word = format!("{}\n{}\n{}", "x".repeat(700), paragraphs(30), "y".repeat(900)); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_word), &[], "long words"); + let breaks = (0..200).map(|i| if i % 5 == 0 { String::new() } else { format!("Line {i} ends here ") }).collect::>().join("\n"); + took += same_as_whole_rest(db, &refs(&frames), &story(&breaks), &[], "spaces and empty paragraphs"); + for k in [0usize, 1, 2, 3] { + let tall: Vec = (0..4).map(|i| sized(200.0 * i as f64, 120.0, 14.0 * (k + i + 1) as f64)).collect(); + took += same_as_whole_rest(db, &refs(&tall), &story(&breaks), &[], &format!("frame heights {k}")); + } + + // Style runs and inline graphics cut where frames end, and empty runs as given. + // Some runs start with a glyph choice, which a run cut after its first character loses. + let mut styled = vec![]; + let text = paragraphs(90); + let words: Vec<&str> = text.split_inclusive(' ').collect(); + for (i, three) in words.chunks(3).enumerate() { + let text = three.concat(); + let mut style = [&plain, &big, &small][i % 3].clone(); + if i % 4 == 0 { + let ch = text.chars().next().unwrap_or('I'); + style.glyph_alternate = Some(vectorcraft_doc::GlyphChoice { ch, gid: 1, font: "Test".into(), version: String::new() }); + } + styled.push(TextRun::new(text, style)); + if i % 7 == 0 { + styled.push(TextRun::inline(vectorcraft_doc::InlineArt::new("sym"), plain.clone())); + } + if i % 11 == 0 { + styled.push(TextRun::new("", big.clone())); + } + } + took += same_as_whole_rest(db, &refs(&frames), &styled, &[], "style runs"); + + // Numbered lists at two levels, counted on from text before. + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let listed: Vec = (0..120).map(|i| at(1 + i % 3 / 2)).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &listed, "lists"); + + // Justified and hyphenated paragraphs, long enough to run from frame to frame. + let text = "The quick brown fox jumps over the lazy dog, extraordinarily unbelievably. ".repeat(6); + let long_paras = (0..14).map(|_| text.trim_end()).collect::>().join("\n"); + let justified: Vec = (0..14) + .map(|i| ParaStyle { justify: [Justify::JustifyAll, Justify::JustifyLeft][i % 2], hyphenate: i % 3 != 0, ..Default::default() }) + .collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_paras), &justified, "justified and hyphenated"); + // Paragraphs longer than a frame holds, each composed as a whole. + let tall: Vec = (0..5).map(|k| sized(200.0 * k as f64, 160.0, 160.0 + 40.0 * k as f64)).collect(); + let very_long = (0..6).map(|_| text.repeat(4).trim_end().to_string()).collect::>().join("\n"); + for justify in [Justify::Left, Justify::JustifyLeft] { + let composed = [ParaStyle { justify, ..Default::default() }]; + took += same_as_whole_rest(db, &refs(&tall), &story(&very_long), &composed, &format!("long paragraphs, {justify:?}")); + } + + // Columns, vertical type, Shrink Text to Fit, lines aligned to the center, type on a path, + // and a last frame the story overflows. + let mut columns = frames.clone(); + for f in &mut columns { + f.area.columns = 2; + f.area.gutter = 6.0; + } + took += same_as_whole_rest(db, &refs(&columns), &story(¶graphs(120)), &[], "columns"); + let mut vertical = frames.clone(); + for f in &mut vertical { + f.vertical = true; + } + took += same_as_whole_rest(db, &refs(&vertical), &story(¶graphs(80)), &[], "vertical type"); + let mut mixed = frames.clone(); + mixed[1].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 }; + mixed[2].area.vertical_align = vectorcraft_doc::VerticalAlign::Center; + mixed[3] = path_frame(300.0); + took += same_as_whole_rest(db, &refs(&mixed), &story(¶graphs(120)), &[], "other kinds of frame"); + let two = [&frames[0], &frames[1]]; + took += same_as_whole_rest(db, &two, &story(¶graphs(200)), &[], "overflowing last frame"); + + // Short stories, an empty one and one of empty runs. + took += same_as_whole_rest(db, &refs(&frames), &story("Hi"), &[], "short story"); + took += same_as_whole_rest(db, &refs(&frames), &story(""), &[], "empty story"); + took += same_as_whole_rest(db, &refs(&frames), &[TextRun::new("", big.clone()), TextRun::new("", small.clone())], &[], "empty runs"); + assert!(took > 40, "the frames took text: {took}"); +} + +/// A seeded mix of run splits, sizes, paragraph styles and frame sizes: each frame takes the share +/// the whole rest gives it. +#[test] +fn seeded_stories_take_the_shares_the_whole_rest_gives_them() { + let db = FontDb::global(); + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + let words = ["fox", "a", "jumps", "extraordinary", "dog,", "the", "lazy", "over", " ", "quick"]; + for case in 0..12 { + let mut runs = vec![]; + let mut paras = 1; + for _ in 0..(150 + next(250)) { + let mut text = words[next(words.len() as u64) as usize].to_string(); + text.push(if next(6) == 0 { + paras += 1; + '\n' + } else { + ' ' + }); + let style = CharStyle { size: [12.0, 9.0, 16.0][next(3) as usize], tracking: [0.0, 50.0][next(2) as usize], ..Default::default() }; + runs.push(TextRun::new(text, style)); + } + normalize(&mut runs); + let styles: Vec = (0..paras) + .map(|_| ParaStyle { + justify: [Justify::Left, Justify::Center, Justify::JustifyLeft][next(3) as usize], + first_line_indent: [0.0, 12.0][next(2) as usize], + space_after: [0.0, 4.0][next(2) as usize], + ..Default::default() + }) + .collect(); + let frames: Vec = (0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect(); + same_as_whole_rest(db, &frames.iter().collect::>(), &runs, &styles, &format!("case {case}")); + } +} + +/// A long story of short paragraphs through many frames: each frame is measured with about the +/// text it takes and a little more, not with the whole rest of the story. +#[test] +fn many_frames_measure_about_the_text_they_take() { + let db = FontDb::global(); + let text = paragraphs(1500); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let frames: Vec = (0..40).map(|k| frame(200.0 * k as f64)).collect(); + let refs: Vec<&TextObject> = frames.iter().collect(); + let before = fit_bytes(); + let parts = distribute(db, &refs, &story, &[]); + let measured = fit_bytes() - before; + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let placed: usize = parts[..parts.len() - 1].iter().map(|p| runs_len(&p.runs)).sum(); + let longest = text.split('\n').map(str::len).max().unwrap_or(0); + assert!(placed > 0 && placed < text.len() / 4, "the frames hold part of the story: {placed} of {}", text.len()); + let bound = 4 * placed + frames.len() * 2 * (MIN_MEASURE + longest); + assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..71a62a7798 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1239,6 +1239,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Type on a path, Shrink Text to Fit, lines aligned other than to the top, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. From 7f18643ada19a3701187c69a4af33d2673d6287f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:12:59 -0800 Subject: [PATCH 2/2] Type: measuring a threaded frame copies its own text once Each measurement of a frame's share started from a copy of the whole frame, its own runs included, and then replaced the runs. The frame is now copied once without its own text, and each measurement copies that. A test counts the frame text copied to measure frames that already hold text. --- crates/text/src/thread.rs | 25 ++++++++++++++++++++++++- crates/text/src/thread/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 0abd172bff..7850fcc717 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -96,6 +96,25 @@ fn fit_bytes() -> usize { FIT_BYTES.get() } +#[cfg(test)] +thread_local! { + /// The bytes of frames' own text copied on this thread to measure them ([`probe_copied`]). + static PROBE_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of frames' own text copied on this thread to measure them. +#[cfg(test)] +fn probe_copied() -> usize { + PROBE_COPIED.get() +} + +/// A copy of `frame` to measure text in, its own text counted as copied ([`probe_copied`]). +fn copy_to_measure(frame: &TextObject) -> TextObject { + #[cfg(test)] + PROBE_COPIED.set(PROBE_COPIED.get() + runs_len(&frame.runs)); + frame.clone() +} + /// The fewest bytes of story a frame is measured with before the measured text grows. const MIN_MEASURE: usize = 512; @@ -372,8 +391,12 @@ pub fn distribute_continuing_levels( let n = if i + 1 == frames.len() { len } else { + // The frame without its own text, copied once and then for each measurement. + let mut base = copy_to_measure(f); + base.runs = Vec::new(); + base.paras = Vec::new(); let probe = |runs: Vec| { - let mut probe = (*f).clone(); + let mut probe = copy_to_measure(&base); let styles = slice_paras(paras, first_para, &runs); probe.runs = runs; probe.set_paragraph_styles(styles); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 5ae49442fb..eac74d635f 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -682,6 +682,29 @@ fn many_frames_measure_about_the_text_they_take() { assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); } +/// Frames that already hold text and take a story over several measurements each: each frame's own +/// text is copied once to measure it, not once for each measurement. +#[test] +fn frames_with_text_copy_it_once_to_measure_it() { + let db = FontDb::global(); + let text = paragraphs(600); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let mut frames: Vec = (0..4).map(|k| sized(400.0 * k as f64, 300.0, 600.0)).collect(); + for f in &mut frames { + f.runs = vec![TextRun::new("Old text. ".repeat(500), CharStyle::default())]; + } + let refs: Vec<&TextObject> = frames.iter().collect(); + let (fit_before, copied_before) = (fit_bytes(), probe_copied()); + let parts = distribute(db, &refs, &story, &[]); + let (measured, copied) = (fit_bytes() - fit_before, probe_copied() - copied_before); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let first = runs_len(&parts[0].runs); + assert!(measured > 2 * first, "the first frame is measured more than once: {measured} bytes for {first}"); + let own: usize = frames[..frames.len() - 1].iter().map(|f| runs_len(&f.runs)).sum(); + assert_eq!(copied, own, "each frame before the last copies its own text once to measure it"); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global();