Files
vectorcraft/crates/text/src/thread.rs
T
dotCooCoo b513c631f9 Type: threaded paths and frames aligned off the top measure the paragraphs they need
A threaded story measured each type-on-a-path frame, and each area frame
whose lines align to the center, the bottom or justify vertically, with
the whole rest of the story. A story of 1,500 one-line paragraphs through
40 such frames was laid out over about 2.1 million bytes while the frames
were measured.

- Area type without Shrink Text to Fit now takes the paragraph probe for
  every vertical alignment. A reflow pass that places less text is
  undone, so when the final layout stops before the last paragraph break
  measured, every pass placed the same lines as with the whole rest.
- Type on a path takes it when its line starts at the start bracket: no
  Dynamic Text, Optical Margin Alignment or Roman Hanging Punctuation,
  the first paragraph aligned Left, Justify Left or Auto, and vertical
  type or no right-to-left text in the rest of the story. The story is
  scanned for right-to-left text once per distribution.
- Shrink Text to Fit, the other path settings and a share that ends in
  the story's last paragraph are still measured with the whole rest.

The same 40 frames are now measured over 21,035 bytes (aligned to the
center) and 20,583 bytes (paths aligned Left). Each frame's share is
unchanged: the tests compare every slice with the whole-rest result for
both distribution entry points, across alignments, frame shapes, wraps,
path settings, right-to-left text, styles, lists and seeded stories.
2026-11-13 10:31:42 -08:00

502 lines
22 KiB
Rust

//! Threaded text: one story flowing through several area-type frames in order.
//!
//! Each frame keeps its own slice of the story as ordinary runs, so rendering, export and hit
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
//! with [`distribute`].
use vectorcraft_doc::text::ListCounts;
use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun};
use crate::edit::{floor_char, normalize, runs_len};
use crate::{FontDb, layout};
/// The story: every frame's runs joined in thread order.
pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
let mut runs: Vec<TextRun> = frames.iter().flat_map(|f| f.runs.iter().cloned()).collect();
normalize(&mut runs);
runs
}
/// The story's paragraph styles, one per story paragraph. A frame whose slice ends inside a
/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style
/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
/// An empty frame that was just restyled keeps its style against unedited empty frames after it.
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
story_paras_restyled(frames, later_wins, &[])
}
/// [`story_paras`] that also knows which frames' last paragraph was just restyled
/// (`earlier_wins[k]`, read for frames with text). Such a frame's last paragraph keeps its style
/// against the unedited first paragraph of the frame after it, and of frames after unedited empty
/// frames, even when the frame ends with a paragraph break. When the later frame's first paragraph
/// was restyled too (`later_wins`), the later frame's style wins.
pub fn story_paras_restyled(frames: &[&TextObject], later_wins: &[bool], earlier_wins: &[bool]) -> Vec<ParaStyle> {
let mut out: Vec<ParaStyle> = vec![];
let mut prev_empty_tail = true;
let mut empty_tail_restyled = false;
let mut tail_restyled = false;
for (k, f) in frames.iter().enumerate() {
let text = f.plain_text();
let restyled = later_wins.get(k).copied().unwrap_or(false);
let mut styles = f.paragraph_styles().into_iter();
if k > 0
&& let Some(first) = styles.next()
&& (restyled || (prev_empty_tail && !tail_restyled && !(text.is_empty() && empty_tail_restyled)))
&& let Some(last) = out.last_mut()
{
*last = first;
}
out.extend(styles);
// An empty frame has no paragraph boundary of its own. Keep the preceding frame's
// boundary state so another empty frame cannot replace a paragraph's edited style.
if !text.is_empty() {
prev_empty_tail = text.ends_with('\n') || out.is_empty();
empty_tail_restyled = false;
tail_restyled = earlier_wins.get(k).copied().unwrap_or(false);
} else if restyled {
empty_tail_restyled = true;
}
}
out
}
/// Paragraph styles of the slice `runs` of a story starting at story paragraph `first`.
fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaStyle> {
let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>();
let fallback = paras.last().cloned().unwrap_or_default();
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
}
/// How many bytes of `probe`'s runs fit in it (whole lines; a following break space or paragraph
/// break stays with the line that ends there, but on a path, where a space with no room
/// 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<usize>) {
#[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(), None);
}
let mut end = lay.lines.last().map_or(0, |l| l.end);
if lay.on_path {
return (end, None);
}
let unplaced = 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, 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<usize> = 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()
}
#[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<usize> = 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;
/// 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<Item = (&'a str, &'a TextRun, bool)> {
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<TextRun> {
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<TextRun> {
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<usize> {
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<TextRun> {
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). `no_rtl`: no paragraph of
/// the rest resolves right to left on its own (no character [`crate::layout::starts_bidi`] holds
/// and no right-to-left Character Direction).
///
/// Area type without Shrink Text to Fit 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. Lines
/// aligned to the center, the bottom or justified vertically move by the space the placed lines
/// leave, and a frame that is not a plain rectangle flows them again from there. A pass that places
/// less text than the one before is undone, so no pass places more text than the final layout; when
/// that layout stops before the last paragraph break measured, every pass stopped at the same line
/// as with the whole rest.
///
/// Type on a path is measured the same way when its line starts at the start bracket whatever text
/// follows: no Dynamic Text, Optical Margin Alignment or Roman Hanging Punctuation, the first
/// paragraph aligned Left, Justify Left or Auto, and no right-to-left text (`no_rtl`, and the first
/// paragraph's direction not right to left) unless the type is vertical. Each glyph's step along the
/// path then depends only on the glyphs before it, so when the first glyph that does not fit comes
/// before the last paragraph break measured, the path 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<TextRun>) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize, no_rtl: bool) -> usize {
let on_path = matches!(frame.kind, TextKind::OnPath { .. });
let by_paragraph = match frame.kind {
TextKind::Area { .. } => frame.area.fit.min_scale().is_none(),
TextKind::OnPath { .. } => path_starts_at_bracket(&probe(Vec::new()), no_rtl),
_ => false,
};
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)));
let first_out = if on_path { Some(n) } else { unplaced };
if first_out.is_some_and(|u| u + 1 < end) {
return n;
}
want = end.saturating_mul(2);
}
}
fit(db, &probe(rest.all())).0
}
/// Whether type on a path made as `head` (the frame with its first paragraph's style) places its
/// line from the start bracket, glyph by glyph, whatever text follows: see [`fit_rest`].
fn path_starts_at_bracket(head: &TextObject, no_rtl: bool) -> bool {
use vectorcraft_doc::{Justify, ParaDirection};
let para = head.para_at(0);
!head.dynamic_text
&& !head.optical_margin
&& !para.roman_hanging
&& matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft)
&& (head.vertical || (no_rtl && para.direction != Some(ParaDirection::RightToLeft)))
}
/// The end of the last character of `runs` that makes its paragraph resolve right to left
/// ([`crate::layout::starts_bidi`], or a run with the right-to-left Character Direction); 0 when
/// there is none.
fn rtl_end(runs: &[TextRun]) -> usize {
let mut off = 0;
let mut end = 0;
for r in runs {
if r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft) && !r.text.is_empty() {
end = off + r.text.len();
} else if let Some((i, c)) = r.text.char_indices().rev().find(|&(_, c)| crate::layout::starts_bidi(c)) {
end = off + i + c.len_utf8();
}
off += r.text.len();
}
end
}
/// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the
/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it
/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Slice {
pub runs: Vec<TextRun>,
pub paras: Vec<ParaStyle>,
pub list_count: u32,
pub list_level_counts: Vec<u32>,
/// The first paragraph continues from the frame before
/// ([`TextObject::first_para_continues`]).
pub continues: bool,
}
impl Slice {
/// The list counts the slice's frame starts from ([`TextObject::list_counts`]).
pub fn list_counts(&self) -> ListCounts {
ListCounts::from_parts(self.list_count, &self.list_level_counts)
}
}
/// The counts of numbered list paragraphs at each level after the paragraphs of `runs` (styles
/// `paras`, one per paragraph) but the last one, counted on from `counts` as the layout counts them
/// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the
/// next frame, which counts it.
fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut counts: ListCounts) -> ListCounts {
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
let mut pieces: Vec<&str> = text.split('\n').collect();
pieces.pop();
for (piece, para) in pieces.iter().zip(paras) {
vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut counts);
}
counts
}
/// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it
/// does unless that byte is a paragraph break. After an empty share (`None`) it takes `was`, the
/// mark of the frame with the empty share.
fn continues_after(before: Option<u8>, was: bool) -> bool {
before.map_or(was, |b| b != b'\n')
}
/// For each of `frames` in thread order, whether its first paragraph continues from the frames
/// before it ([`TextObject::first_para_continues`]): the text of those frames is not empty and
/// does not end with a paragraph break.
pub fn continued(frames: &[&TextObject]) -> Vec<bool> {
let mut on = false;
frames
.iter()
.map(|f| {
let this = on;
on = continues_after(f.runs.iter().rev().find_map(|r| r.text.bytes().last()), on);
this
})
.collect()
}
/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each
/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
/// split between frames has its style in both, and each slice that starts inside a paragraph is
/// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted
/// on from the frames before it, at each level. Each frame's share is measured with that mark and
/// those counts.
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
distribute_continuing_levels(db, frames, runs, paras, false, ListCounts::default())
}
/// [`distribute`] for `runs` that follow text before them: the first frame's share is measured,
/// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs
/// at level 1 are counted on from `list_count` ([`distribute_continuing_levels`] takes the counts
/// of every level).
pub fn distribute_continuing(
db: &FontDb,
frames: &[&TextObject],
runs: &[TextRun],
paras: &[ParaStyle],
continued: bool,
list_count: u32,
) -> Vec<Slice> {
distribute_continuing_levels(db, frames, runs, paras, continued, ListCounts::from_parts(list_count, &[]))
}
/// [`distribute_continuing`] with the first frame's numbered list paragraphs counted on from
/// `counts` at each level.
pub fn distribute_continuing_levels(
db: &FontDb,
frames: &[&TextObject],
runs: &[TextRun],
paras: &[ParaStyle],
continued: bool,
mut counts: ListCounts,
) -> Vec<Slice> {
let story_len = runs_len(runs);
let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: story_len };
// Where the story's right-to-left text ends ([`rtl_end`]), read once, for type on a path.
let story_rtl_end = std::cell::OnceCell::new();
// Once the story is used up, what is left is one empty run with the style there.
let mut used_up: Option<TextRun> = 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 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 {
// 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<TextRun>| {
let mut probe = copy_to_measure(&base);
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
};
// The rest is the story after its first `story_len - len` bytes.
let no_rtl = len == 0
|| (matches!(f.kind, TextKind::OnPath { .. })
&& !f.vertical
&& *story_rtl_end.get_or_init(|| rtl_end(runs)) <= story_len.saturating_sub(len));
fit_rest(db, probe, f, rest, want, no_rtl)
};
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: 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 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
}
#[cfg(test)]
mod tests;