diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4f3566b97d..fedd795d78 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -34,7 +34,7 @@ mod ruby; mod shaped; mod vertical; -pub(crate) use bidi::{is_rtl, para_bidi}; +pub(crate) use bidi::{is_rtl, para_bidi, starts_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; diff --git a/crates/text/src/layout/bidi.rs b/crates/text/src/layout/bidi.rs index e4b6263244..3f9872983d 100644 --- a/crates/text/src/layout/bidi.rs +++ b/crates/text/src/layout/bidi.rs @@ -4,15 +4,21 @@ use super::{BidiInfo, Level, ParaDirection, Range, SGlyph}; /// without one, from its first strong character. `None` for the usual paragraph with nothing right /// to left in it (no right-to-left character or direction), which skips the algorithm. pub(crate) fn para_bidi(text: &str, direction: Option) -> Option> { - use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO}; let rtl_set = direction == Some(ParaDirection::RightToLeft); - if !rtl_set && !text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI)) { + if !rtl_set && !text.chars().any(starts_bidi) { return None; } let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); Some(BidiInfo::new(text, level)) } +/// Whether character `c` makes [`para_bidi`] resolve a paragraph that holds it: a right-to-left +/// letter, an Arabic number, or a right-to-left embedding, override or isolate. +pub(crate) fn starts_bidi(c: char) -> bool { + use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO}; + matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI) +} + /// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not /// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved /// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2, diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index b78527378d..ff22f3a5e8 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -262,22 +262,41 @@ impl<'a> Rest<'a> { } /// 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). +/// 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 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; +/// 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) -> 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))); - if unplaced.is_some_and(|u| u + 1 < 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); @@ -286,6 +305,35 @@ fn fit_rest(db: &FontDb, probe: impl Fn(Vec) -> TextObject, frame: &Tex 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`]). @@ -379,7 +427,10 @@ pub fn distribute_continuing_levels( continued: bool, mut counts: ListCounts, ) -> Vec { - let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) }; + 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()); @@ -415,7 +466,12 @@ pub fn distribute_continuing_levels( probe.first_para_continues = continues; probe }; - fit_rest(db, probe, f, rest, want) + // 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); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 943aeb2973..7554a808f9 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -19,6 +19,14 @@ fn path_frame(length: f64) -> TextObject { t } +/// Type on a curved path about 300 long. +fn curved_path_frame() -> TextObject { + let mut t = path_frame(300.0); + let curve = kurbo::CubicBez::new((0.0, 0.0), (100.0, -120.0), (200.0, 120.0), (300.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&curve, 0.1)), start: 0.0, end: None }; + t +} + /// Type on a path that overflows ends its line where its text stops fitting, as area type's /// last line does, so a story flows on from there through area type and type on a path alike. #[test] @@ -543,6 +551,7 @@ fn paragraphs(n: usize) -> String { /// 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::VerticalAlign; use vectorcraft_doc::text::{NumberStyle, ParaList}; let db = FontDb::global(); let plain = CharStyle::default(); @@ -649,6 +658,123 @@ fn frames_take_the_shares_the_whole_rest_gives_them() { let two = [&frames[0], &frames[1]]; took += same_as_whole_rest(db, &two, &story(¶graphs(200)), &[], "overflowing last frame"); + // Lines aligned to the center, the bottom or justified vertically, in rectangles, ellipses and + // frames that wrap around an object, with Shrink Text to Fit in one frame, and a story whose + // last paragraph runs through several frames. + let last_long = format!("{}\n{}", paragraphs(6), "The quick brown fox jumps over the lazy dog. ".repeat(40)); + for align in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + let mut rects = frames.clone(); + for f in &mut rects { + f.area.vertical_align = align; + } + took += same_as_whole_rest(db, &refs(&rects), &story(¶graphs(120)), &spaced, &format!("{align:?}")); + took += same_as_whole_rest(db, &refs(&rects), &story(&breaks), &[], &format!("{align:?}, empty paragraphs")); + took += same_as_whole_rest(db, &refs(&rects), &styled, &[], &format!("{align:?}, style runs")); + took += same_as_whole_rest(db, &refs(&rects), &story(¶graphs(120)), &listed, &format!("{align:?}, lists")); + took += same_as_whole_rest(db, &refs(&rects), &story(&last_long), &[], &format!("{align:?}, long last paragraph")); + let mut ellipses = rects.clone(); + for (k, f) in ellipses.iter_mut().enumerate() { + let x = 200.0 * k as f64; + f.kind = TextKind::Area { frame: shapes::ellipse(Rect::new(x, 0.0, x + 160.0, 90.0)) }; + } + took += same_as_whole_rest(db, &refs(&ellipses), &story(¶graphs(120)), &[], &format!("{align:?}, ellipses")); + took += same_as_whole_rest(db, &refs(&ellipses), &story(&long_paras), &justified, &format!("{align:?}, ellipses, justified")); + let mut wrapped = rects.clone(); + for (k, f) in wrapped.iter_mut().enumerate() { + let x = 200.0 * k as f64; + f.wrap = vec![vectorcraft_doc::WrapShape { path: shapes::ellipse(Rect::new(x + 40.0, 10.0, x + 90.0, 40.0)), wrap: Default::default() }]; + } + took += same_as_whole_rest(db, &refs(&wrapped), &story(¶graphs(120)), &[], &format!("{align:?}, wrapped")); + let mut shrink = rects.clone(); + shrink[2].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 }; + took += same_as_whole_rest(db, &refs(&shrink), &story(¶graphs(120)), &[], &format!("{align:?}, Shrink Text to Fit")); + } + + // Type on a path: lines that start at the start bracket, and lines whose place or length + // depends on the text after them (centered or right-aligned, hanging punctuation, Dynamic + // Text, right-to-left text), horizontal and vertical. + let paths = |edit: Edit| -> Vec { + (0..6) + .map(|k| { + let mut t = if k % 2 == 0 { path_frame(300.0) } else { curved_path_frame() }; + if k % 3 == 2 { + // Brackets inside the path, with room for the line's ends to hang past them. + if let TextKind::OnPath { start, end, .. } = &mut t.kind { + (*start, *end) = (0.1, Some(0.8)); + } + } + edit(&mut t); + t + }) + .collect() + }; + let hebrew = "\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd} ".repeat(4); + let rtl_early = format!("{}\n{}\n{}", paragraphs(2), hebrew.trim_end(), paragraphs(100)); + let ends_unhung = format!("{}\nlast words", paragraphs(120)); + let rtl_late = format!("{}\n{}\n{}", paragraphs(80), hebrew.trim_end(), paragraphs(20)); + let whole = story(¶graphs(120)); + let at = paragraphs(60).len(); + let directed = [ + slice_runs(&whole, 0, at), + vec![TextRun::new( + " set right to left ", + CharStyle { char_direction: Some(vectorcraft_doc::ParaDirection::RightToLeft), ..Default::default() }, + )], + slice_runs(&whole, at, runs_len(&whole)), + ] + .concat(); + type Edit = fn(&mut TextObject); + let edits: [(&str, Edit); 15] = [ + ("path", |_| {}), + ("path, Left", |t| t.para.justify = Justify::Left), + ("path, Auto", |t| t.para.justify = Justify::Auto), + ("path, Justify Left", |t| t.para.justify = Justify::JustifyLeft), + ("path, Center", |t| t.para.justify = Justify::Center), + ("path, Right", |t| t.para.justify = Justify::Right), + ("path, Justify All", |t| t.para.justify = Justify::JustifyAll), + ("path, spacing", |t| t.path_spacing = 8.0), + ("path, Optical Margin Alignment", |t| t.optical_margin = true), + ("path, Roman Hanging Punctuation", |t| t.para.roman_hanging = true), + ("path, Dynamic Text", |t| t.dynamic_text = true), + ("path, right to left", |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::RightToLeft)), + ("path, Auto right to left", |t| { + t.para.justify = Justify::Auto; + t.para.direction = Some(vectorcraft_doc::ParaDirection::RightToLeft); + }), + ("vertical path", |t| t.vertical = true), + ("vertical path, Center", |t| { + t.vertical = true; + t.para.justify = Justify::Center; + }), + ]; + for (case, edit) in edits { + let on = paths(edit); + took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &[], case); + took += same_as_whole_rest(db, &refs(&on), &story(&rtl_early), &[], &format!("{case}, right-to-left paragraph early")); + took += same_as_whole_rest(db, &refs(&on), &story(&rtl_late), &[], &format!("{case}, right-to-left paragraph late")); + took += same_as_whole_rest(db, &refs(&on), &directed, &[], &format!("{case}, right-to-left Character Direction")); + took += same_as_whole_rest(db, &refs(&on), &story(&last_long), &[], &format!("{case}, long last paragraph")); + // The story's last character does not hang, the last one measured does. + took += same_as_whole_rest(db, &refs(&on), &story(&ends_unhung), &[], &format!("{case}, story ending in a letter")); + } + let on = paths(|_| {}); + took += same_as_whole_rest(db, &refs(&on), &styled, &[], "path, style runs"); + took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &listed, "path, lists"); + took += same_as_whole_rest(db, &refs(&on), &story(&breaks), &[], "path, spaces and empty paragraphs"); + let story_paras: Vec = (0..120) + .map(|i| ParaStyle { justify: [Justify::Left, Justify::Center, Justify::Auto][i % 3], roman_hanging: i % 5 == 0, ..Default::default() }) + .collect(); + took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &story_paras, "path, paragraph styles"); + let mut between = frames.clone(); + for (k, f) in between.iter_mut().enumerate() { + if k % 2 == 1 { + *f = path_frame(260.0); + } else { + f.area.vertical_align = VerticalAlign::Bottom; + } + } + took += same_as_whole_rest(db, &refs(&between), &story(¶graphs(120)), &[], "paths between area type"); + // 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"); @@ -674,6 +800,9 @@ fn seeded_stories_take_the_shares_the_whole_rest_gives_them() { let mut paras = 1; for _ in 0..(150 + next(250)) { let mut text = words[next(words.len() as u64) as usize].to_string(); + if next(60) == 0 { + text = "\u{5e9}\u{5dc}\u{5d5}\u{5dd}".to_string(); + } text.push(if next(6) == 0 { paras += 1; '\n' @@ -692,30 +821,80 @@ fn seeded_stories_take_the_shares_the_whole_rest_gives_them() { ..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(); + let mut 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(); + // Lines aligned every way, and type on a path, horizontal or vertical. + for f in &mut frames { + use vectorcraft_doc::VerticalAlign; + f.area.vertical_align = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify][next(4) as usize]; + if next(4) == 0 { + *f = if next(2) == 0 { path_frame(80.0 + next(400) as f64) } else { curved_path_frame() }; + f.vertical = next(3) == 0; + } + } 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(); +/// Each of `frames` takes its share of a long story of short paragraphs measured with about the text +/// it takes and a little more, not with the whole rest of the story. +fn measures_about_the_text_taken(db: &FontDb, frames: &[TextObject], case: &str) { 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"); + assert_eq!(joined, text, "{case}: 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()); + assert!(placed > 0 && placed < text.len() / 4, "{case}: 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()); + assert!(measured <= bound, "{case}: measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); +} + +/// 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. That holds for area type +/// aligned to the top, the center or the bottom or justified vertically, in rectangles and other +/// shapes, and for type on a path whose line starts at the start bracket, horizontal or vertical. +#[test] +fn many_frames_measure_about_the_text_they_take() { + use vectorcraft_doc::VerticalAlign; + let db = FontDb::global(); + let frames: Vec = (0..40).map(|k| frame(200.0 * k as f64)).collect(); + measures_about_the_text_taken(db, &frames, "top"); + for align in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + let mut aligned = frames.clone(); + for f in &mut aligned { + f.area.vertical_align = align; + } + measures_about_the_text_taken(db, &aligned, &format!("{align:?}")); + let mut shaped = aligned.clone(); + for (k, f) in shaped.iter_mut().enumerate() { + let x = 200.0 * k as f64; + f.kind = TextKind::Area { frame: shapes::ellipse(Rect::new(x, 0.0, x + 160.0, 90.0)) }; + } + measures_about_the_text_taken(db, &shaped, &format!("{align:?}, ellipses")); + } + for justify in [Justify::Left, Justify::JustifyLeft, Justify::Auto] { + let paths: Vec = (0..40) + .map(|_| { + let mut t = path_frame(300.0); + t.para.justify = justify; + t + }) + .collect(); + measures_about_the_text_taken(db, &paths, &format!("path, {justify:?}")); + } + let vertical: Vec = (0..40) + .map(|_| { + let mut t = path_frame(300.0); + t.vertical = true; + t + }) + .collect(); + measures_about_the_text_taken(db, &vertical, "vertical path"); } /// Frames that already hold text and take a story over several measurements each: each frame's own diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..7edcdc2acd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1268,7 +1268,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (, ). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`. 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. + 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`). Area type with its lines aligned to the center, the bottom or justified vertically, and type on a path whose first paragraph is aligned Left, Justify Left or Auto, are measured the same way: the same story through 40 area frames aligned to the center was laid out over 2,126,762 bytes and is now laid out over 21,035, and through 40 paths aligned Left over 2,135,202 bytes and now over 20,583 (same test). Shrink Text to Fit, Dynamic Text, Optical Margin Alignment and Roman Hanging Punctuation on a path, a path aligned another way, horizontal type on a path whose first paragraph is set right to left or with right-to-left text in the rest of the story, 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.