Merge pull request #2065 from dotCooCoo/feat/auto-size-type-fewer-layouts

Type: Auto Size area type in columns usually lays the columns out twice an edit
This commit is contained in:
BFlat
2026-10-16 04:22:45 +07:00
committed by GitHub
6 changed files with 328 additions and 11 deletions
+61 -11
View File
@@ -124,6 +124,15 @@ fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
(finite && b.height() > 1e-6 && b.width() > 1e-6 && (area - b.area()).abs() <= b.area() * 1e-6).then_some(b)
}
#[cfg(test)]
thread_local! {
/// Tests: how many times [`auto_height`] laid out text in its columns on this thread.
pub(crate) static COLUMN_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: when set, [`auto_height`] lays out the columns at every step of its bisection on
/// this thread.
pub(crate) static FULL_SEARCH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Auto Size: move the bottom of a rectangular area type frame to just below its last line (plus
/// the inset), so the frame's height follows the text. Text in several columns gets the least
/// height (to 0.01 pt) at which it fits. True when the frame changed.
@@ -158,21 +167,30 @@ fn auto_height(t: &mut TextObject) -> bool {
// The least height (to 0.01 pt) at which the columns hold the text.
probe.area.columns = cols;
let fits = |probe: &mut TextObject, h: f64| {
#[cfg(test)]
COLUMN_LAYOUTS.with(|c| c.set(c.get() + 1));
with_size(probe, b.width(), h);
!vectorcraft_text::layout(db, probe).overflow
};
if !fits(&mut probe, h) {
return false;
}
let (mut lo, mut hi) = (1.0, h);
for _ in 0..24 {
if hi - lo <= 0.01 {
break;
// The bisection runs on the one column's lines as `columns_hold` places them in the columns.
// The columns are then laid out at the upper height it ends on and, when they hold the text
// there, at the lower one, unless that is the 1 pt it starts from. When the one column
// overflows, when text wraps around other objects, or when the columns don't hold the text
// at the upper height or do hold it at the lower one, the columns are laid out at every step
// of the bisection.
let quick = !lay.overflow && t.wrap.is_empty();
#[cfg(test)]
let quick = quick && !FULL_SEARCH.with(std::cell::Cell::get);
let ends = quick.then(|| least_height(1.0, h, |x| columns_hold(&lay.lines, cols, b.y0, inset, x)));
h = match ends {
Some((lo, hi)) if fits(&mut probe, hi) && (lo <= 1.0 || !fits(&mut probe, lo)) => hi,
_ => {
if !fits(&mut probe, h) {
return false;
}
least_height(1.0, h, |x| fits(&mut probe, x)).1
}
let mid = (lo + hi) * 0.5;
if fits(&mut probe, mid) { hi = mid } else { lo = mid }
}
h = hi;
};
}
if (h - b.height()).abs() <= 1e-6 {
return false;
@@ -189,6 +207,38 @@ fn auto_height(t: &mut TextObject) -> bool {
true
}
/// The bisection [`auto_height`] runs for text in columns, from `lo` (taken not to fit) and `hi`
/// (where it fits): at most 24 halvings, until they are at most 0.01 pt apart → (lo, hi).
fn least_height(mut lo: f64, mut hi: f64, mut fits: impl FnMut(f64) -> bool) -> (f64, f64) {
for _ in 0..24 {
if hi - lo <= 0.01 {
break;
}
let mid = (lo + hi) * 0.5;
if fits(mid) { hi = mid } else { lo = mid }
}
(lo, hi)
}
/// Do `cols` columns of a frame `h` tall (its top at `y0`, the text `inset` inside it) hold
/// `lines`, the text's lines laid out in one column as wide as each of them from the same top?
/// Each column takes the next lines while they end within its bottom, as layout tests them
/// (`baseline + descent <= bottom + 0.01`). A column's first line sits its
/// [`first_baseline`](vectorcraft_text::LineInfo::first_baseline) below `y0 + inset`, and the
/// lines after it keep their distances from it. A last line with no characters (after a return
/// that ends the text) may be left out, as layout leaves it out without overflowing.
fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inset: f64, h: f64) -> bool {
let Some(first) = lines.first() else { return true };
let bottom = y0 + h - inset + 0.01;
let mut next = 0;
for _ in 0..cols {
let Some(start) = lines.get(next) else { break };
let up = (start.baseline - start.first_baseline) - (first.baseline - first.first_baseline);
next += lines.iter().skip(next).take_while(|l| l.baseline + l.descent - up <= bottom).count();
}
next >= lines.len() || (next + 1 == lines.len() && lines.last().is_some_and(|l| l.start == l.end))
}
/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks
/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
+215
View File
@@ -205,6 +205,221 @@ fn auto_height_in_columns_and_by_hand() {
assert!(s.execute("text.areaOptions", &json!({"fit": "autoHeight"})).is_err(), "point type has no area options");
}
/// Typing at the start of a story in Auto Size area type with 2 columns lays the columns out at
/// most twice a keystroke: the bisection for their height runs on the one column's lines.
#[test]
fn auto_height_in_columns_lays_out_the_columns_twice_a_keystroke() {
use crate::cmd::typecmd::COLUMN_LAYOUTS;
let mut s = session();
let story = format!("{STORY}\n").repeat(4);
let id = area(&mut s, 300.0, 100.0, &story, json!({"fit": "autoHeight"}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12})).unwrap();
let before = frame(&s, id);
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
// The caret goes before the first character.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 41.0, 45.0), v).unwrap();
}
let typed = "More words at the start, enough for another line or two in the first column. ";
for ch in typed.chars() {
let start = COLUMN_LAYOUTS.with(std::cell::Cell::get);
s.tool_text(&ch.to_string(), v).unwrap();
let laid = COLUMN_LAYOUTS.with(std::cell::Cell::get) - start;
assert!(laid <= 2, "{ch:?} laid out the columns {laid} times");
}
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), format!("{typed}{story}"));
assert!(frame(&s, id).height() > before.height(), "the frame follows the text: {before:?} → {:?}", frame(&s, id));
assert!(!overflows(&s, id));
}
/// Two sessions run the same keystrokes, edits, restyles, Area Type Options, undos and redos in
/// Auto Size area type with 2 and then 3 columns, the second with `FULL_SEARCH` on (the columns
/// laid out at every step of the bisection), and the type is the same in both after each step.
#[test]
fn auto_height_in_columns_matches_the_full_search() {
use crate::cmd::typecmd::FULL_SEARCH;
enum Step {
Click(f64, f64),
Type(&'static str),
Key(ToolKey),
Exec(&'static str, Value),
}
use Step::*;
let v = ViewInfo::default();
let story = format!("{STORY}\n{STORY} {STORY}\nShort one.\n{STORY}");
let make = || {
let mut s = session();
let id = area(&mut s, 300.0, 100.0, &story, json!({"fit": "autoHeight"}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12})).unwrap();
(s, id)
};
let ((mut quick, id), (mut full, full_id)) = (make(), make());
assert_eq!(id, full_id);
let run = |s: &mut Session, step: &Step| match step {
Click(x, y) => {
s.select_tool("type", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, *x, *y), v).unwrap();
}
}
Type(typed) => {
for ch in typed.chars() {
s.tool_text(&ch.to_string(), v).unwrap();
}
}
Key(k) => {
s.tool_key(*k, Mods::default(), v).unwrap();
}
Exec(cmd, p) => {
s.execute(cmd, p).unwrap();
}
};
let mut steps = vec![
// Typing at the start, a new paragraph and Backspace, then typing in the second column.
Click(41.0, 45.0),
Type("Words typed first "),
Key(ToolKey::Enter),
Type("A new paragraph of a few words"),
Key(ToolKey::Backspace),
Key(ToolKey::Backspace),
Key(ToolKey::Escape),
Click(200.0, 50.0),
Type(" and more in the second column"),
Key(ToolKey::Escape),
// A long insertion and a deletion, larger type, paragraph spacing.
Exec("text.editRange", json!({"id": id.0, "start": 20, "end": 20, "insert": format!("{STORY} {STORY}")})),
Exec("text.editRange", json!({"id": id.0, "start": 0, "end": 60})),
Exec("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 12, "size": 30})),
Exec("text.setRangeStyle", json!({"id": id.0, "start": 150, "end": 170, "size": 20})),
Exec("select.set", json!({"ids": [id.0]})),
Exec("text.setFormat", json!({"spaceBefore": 6, "spaceAfter": 4})),
// Three columns, typing in them, Area Type Options and the leading model.
Exec("text.areaOptions", json!({"columns": 3})),
Click(41.0, 60.0),
Type("More"),
Key(ToolKey::Escape),
Exec("select.set", json!({"ids": [id.0]})),
Exec("text.areaOptions", json!({"gutter": 30, "inset": 6})),
Exec("text.areaOptions", json!({"firstBaseline": "capHeight"})),
Exec("text.areaOptions", json!({"firstBaseline": "fixed", "firstBaselineMin": 20})),
Exec("text.areaOptions", json!({"firstBaseline": "leading", "firstBaselineMin": 0})),
Exec("text.setFormat", json!({"leadingModel": "emBoxTop"})),
];
let start = undo_len(&quick);
for (i, step) in steps.iter().enumerate() {
run(&mut quick, step);
FULL_SEARCH.with(|c| c.set(true));
run(&mut full, step);
FULL_SEARCH.with(|c| c.set(false));
assert_eq!(text(&quick, id), text(&full, id), "after step {i}");
}
let edits = undo_len(&quick) - start;
assert_eq!(edits, 14, "three typing sessions and eleven edits, one undo step each");
steps.clear();
steps.extend((0..edits).map(|_| Exec("edit.undo", json!({}))));
steps.extend((0..edits).map(|_| Exec("edit.redo", json!({}))));
for (i, step) in steps.iter().enumerate() {
run(&mut quick, step);
FULL_SEARCH.with(|c| c.set(true));
run(&mut full, step);
FULL_SEARCH.with(|c| c.set(false));
assert_eq!(text(&quick, id), text(&full, id), "after undo or redo {i}");
}
assert_eq!((undo_len(&quick), undo_len(&full)), (start + edits, start + edits));
}
/// A line that starts with a letter smaller than the rest of it can sit higher or lower at the top
/// of a column than `columns_hold` places it. When the columns do not hold the text at the upper
/// height the search ends on, or do hold it at the lower one, Auto Size lays the columns out at
/// every step, and the frame gets the height the full search gives it. In 2 columns, lines in 6 pt
/// alternate with lines that start with a 6 pt letter and go on in 30 pt, with auto leading and
/// with 36 pt leading, and lines in 6 pt are added at the start one at a time.
#[test]
fn auto_height_in_columns_matches_the_full_search_when_lines_start_small() {
use crate::cmd::typecmd::{COLUMN_LAYOUTS, FULL_SEARCH};
let (small, big) = ("small words in six point type\n", "b BIG\n");
let story = format!("{small}{big}").repeat(13);
for leading in [json!("auto"), json!(36)] {
let make = || {
let mut s = session();
let area = json!({"width": 300, "height": 100, "fit": "autoHeight"});
let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 6, "area": area, "text": story})).unwrap()["id"].as_u64().unwrap();
for k in 0..13 {
let start = k * (small.len() + big.len()) + small.len() + 1;
s.execute("text.setRangeStyle", &json!({"id": id, "start": start, "end": start + 4, "size": 30})).unwrap();
}
s.execute("text.setRangeStyle", &json!({"id": id, "leading": leading})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12})).unwrap();
(s, NodeId(id))
};
let ((mut quick, id), (mut full, _)) = (make(), make());
let mut every_step = 0;
for i in 0..12 {
let add = json!({"id": id.0, "start": 0, "end": 0, "insert": "more small words\n"});
let start = COLUMN_LAYOUTS.with(std::cell::Cell::get);
quick.execute("text.editRange", &add).unwrap();
let laid = COLUMN_LAYOUTS.with(std::cell::Cell::get) - start;
every_step += usize::from(laid > 2);
FULL_SEARCH.with(|c| c.set(true));
full.execute("text.editRange", &add).unwrap();
FULL_SEARCH.with(|c| c.set(false));
assert_eq!(text(&quick, id), text(&full, id), "leading {leading}, after line {i}");
}
assert!(every_step > 0, "leading {leading}: the columns were never laid out at every step");
}
}
/// Time typing at the start of a story of 5,000, 20,000 and 50,000 characters in Auto Size area
/// type in 2 and 3 columns: 20 letters typed one at a time with the Type tool, with Auto Size as it
/// runs and with `FULL_SEARCH` on (the columns laid out at every step of the bisection).
/// Run with `cargo test --release -p vectorcraft-engine --lib auto_height_in_columns_typing_timing -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn auto_height_in_columns_typing_timing() {
use crate::cmd::typecmd::{COLUMN_LAYOUTS, FULL_SEARCH};
const WORDS: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. ";
let v = ViewInfo::default();
for n in [5_000, 20_000, 50_000] {
let story = format!("{}\n", WORDS.repeat(6).trim_end()).repeat(n / 600 + 1)[..n].to_string();
for cols in [2, 3] {
let mut row = format!("{n} characters in {cols} columns:");
for full in [false, true] {
FULL_SEARCH.with(|c| c.set(full));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap();
let area = json!({"width": 500, "height": 100, "fit": "autoHeight"});
let id = s.execute("text.create", &json!({"x": 20, "y": 20, "size": 9, "text": story, "area": area})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.areaOptions", &json!({"columns": cols, "gutter": 12})).unwrap();
s.select_tool("type", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 21.0, 24.0), v).unwrap();
}
let layouts = COLUMN_LAYOUTS.with(std::cell::Cell::get);
let mut ms: Vec<f64> = "abcdefghijklmnopqrst"
.chars()
.map(|ch| {
let start = std::time::Instant::now();
s.tool_text(&ch.to_string(), v).unwrap();
start.elapsed().as_secs_f64() * 1000.0
})
.collect();
let per_key = (COLUMN_LAYOUTS.with(std::cell::Cell::get) - layouts) as f64 / ms.len() as f64;
ms.sort_by(f64::total_cmp);
let how = if full { "every step laid out" } else { "Auto Size" };
row += &format!(" {how} {:.1} ms a keystroke ({per_key:.1} column layouts);", ms[ms.len() / 2]);
}
FULL_SEARCH.with(|c| c.set(false));
println!("{row}");
}
}
}
#[test]
fn shrink_text_round_trips_through_the_native_format() {
let mut s = session();
+3
View File
@@ -1836,6 +1836,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
x1: x_end,
ascent: m.asc,
descent: m.desc,
first_baseline: if pen.model == LeadingModel::EmBoxTop { m.top.max(pen.fb_min) } else { m.first_baseline(pen.fb, pen.fb_min) },
start: if i < n { sg[i].byte } else { pr.start },
end: if last_of_para { pr.end } else { sg[end].byte },
glyph_start,
@@ -1915,6 +1916,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
x1: 0.0,
ascent: m.0,
descent: m.1,
first_baseline: 0.0,
start: 0,
end: text_len,
glyph_start: 0,
@@ -2000,6 +2002,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
x1: pe.x,
ascent: m.0,
descent: m.1,
first_baseline: 0.0,
start: 0,
end: text_len,
glyph_start: 0,
+8
View File
@@ -128,6 +128,14 @@ pub struct LineInfo {
pub x1: f64,
pub ascent: f64,
pub descent: f64,
/// Area type: how far the line's baseline sits below the top of its frame cell (inside the
/// inset, with the lines aligned to the top) when the line starts the cell: the metric of the
/// line that Area Type Options › First Baseline picks (the em box top with top-to-top leading),
/// at least the First Baseline minimum. When layout moves a line on from the cell before and
/// the line's first character has smaller metrics than the rest of it, or moves the line down
/// to where it has room (in a frame that is not a rectangle, or beside wrap objects), the line
/// can sit elsewhere. It is 0 for type on a path.
pub first_baseline: f64,
/// Byte range of the line in the plain text (excludes the paragraph's `\n`).
pub start: usize,
pub end: usize,
+40
View File
@@ -613,6 +613,46 @@ fn inset_and_first_baseline_options() {
assert!(cap.lines[0].baseline < base.lines[0].baseline && cap.lines[0].baseline > 5.0);
}
/// `LineInfo::first_baseline`: each line that starts a column has its baseline that far below the
/// column's top inside the inset, for every first baseline option, with and without a minimum and
/// an inset, in both leading models, and for lines of several sizes (each line in one size).
#[test]
fn lines_starting_a_column_sit_at_their_first_baseline() {
let paras = [
("A heading in large type\n", 24.0),
("Body copy in a smaller size that runs over two or three lines of a column.\n", 12.0),
("A middle size\n", 18.0),
];
let runs: Vec<TextRun> = paras.repeat(4).into_iter().map(|(text, size)| run(text, size)).collect();
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
let mut other_sizes = 0;
for model in [vectorcraft_doc::LeadingModel::RomanBaseline, vectorcraft_doc::LeadingModel::EmBoxTop] {
for first_baseline in [FirstBaseline::Ascent, FirstBaseline::CapHeight, FirstBaseline::XHeight, FirstBaseline::Leading, FirstBaseline::Fixed]
{
for (first_baseline_min, inset) in [(0.0, 0.0), (18.0, 6.0)] {
for height in [100.0, 120.0, 140.0] {
let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, 420.0, height), Justify::Left);
t.runs = runs.clone();
t.para.leading_model = model;
let opts = LayoutOptions { columns: 3, gutter: 10.0, first_baseline, first_baseline_min, inset, ..Default::default() };
let l = layout_with(db(), &t, &opts);
let starts: Vec<&LineInfo> =
l.lines.iter().enumerate().filter(|(i, li)| *i == 0 || l.lines[i - 1].region != li.region).map(|(_, li)| li).collect();
assert_eq!(starts.len(), 3, "{model:?} {first_baseline:?} {height}");
other_sizes += starts.iter().filter(|li| li.ascent != starts[0].ascent).count();
for li in starts {
assert!(
(li.baseline - (inset + li.first_baseline)).abs() < 1e-9,
"{model:?} {first_baseline:?} {first_baseline_min} {height}: {li:?}"
);
}
}
}
}
}
assert!(other_sizes >= 20, "lines of another size than the first start {other_sizes} columns");
}
#[test]
fn rows_and_columns_flow_in_order() {
let text = COPY.repeat(2);
File diff suppressed because one or more lines are too long