mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
Merge pull request #7150 from dotCooCoo/fix/thread-frame-measure-needed-paragraphs
Type: a threaded story measures each frame with the paragraphs it needs
This commit is contained in:
+242
-24
@@ -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.
|
||||
@@ -68,21 +68,23 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaS
|
||||
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
|
||||
}
|
||||
|
||||
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
|
||||
/// 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).
|
||||
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> 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<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();
|
||||
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;
|
||||
@@ -90,7 +92,198 @@ 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<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).
|
||||
///
|
||||
/// 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<TextRun>) -> 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
|
||||
@@ -186,31 +379,56 @@ pub fn distribute_continuing_levels(
|
||||
continued: bool,
|
||||
mut counts: ListCounts,
|
||||
) -> Vec<Slice> {
|
||||
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<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 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)
|
||||
// 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
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
@@ -448,6 +449,298 @@ 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<Slice> {
|
||||
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::<Vec<_>>().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<TextObject> = (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<ParaStyle> = (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::<Vec<_>>().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<TextObject> = (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<ParaStyle> = (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::<Vec<_>>().join("\n");
|
||||
let justified: Vec<ParaStyle> = (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<TextObject> = (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::<Vec<_>>().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<ParaStyle> = (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<TextObject> = (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::<Vec<_>>(), &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<TextObject> = (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());
|
||||
}
|
||||
|
||||
/// 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<TextObject> = (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();
|
||||
|
||||
@@ -1253,6 +1253,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.
|
||||
In threaded text, a paragraph format given to the empty paragraph after a frame's last paragraph break stays after the story flows again, in that frame and in the frames the paragraph continues into, unless the same edit also formats the next frame's first paragraph, whose format then applies (VectorCraft's own rule; `thread::tests::restyled_empty_last_paragraph_wins_over_unedited_next_frame`, `cmd::threads::tests::editing_a_frames_empty_last_paragraph_survives_thread_reflow`).
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user