//! 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 { let mut runs: Vec = 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 { 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 { let mut out: Vec = 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 { let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::(); 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) { #[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 = 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 = 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 { 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). `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, Justify All 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. A line aligned to the center or the right, or Auto right to left, starts where the width of /// all its glyphs puts it, and a glyph's advance can be negative (negative tracking), so a shorter /// text can start further along than the whole rest does; such type is measured 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, 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 | Justify::JustifyAll) && (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, pub paras: Vec, pub list_count: u32, pub list_level_counts: Vec, /// 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, 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 { 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 { 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 { 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 { 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 = 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| { 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;