diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index bac0edbe92..41b8b7f5c3 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -61,10 +61,50 @@ pub(crate) struct Params { pub hyphen_limit: usize, } -struct Node { +/// The search gave up: it ran past its budget ([`Measure::line`]). +#[derive(Debug)] +pub(crate) struct Exhausted; + +/// Where each line of a paragraph goes and how wide it is: the measure a line gets may depend on +/// the lines set above it (their heights move it down a shaped frame, or past a wrap object). +pub(crate) trait Measure { + /// What the measure of the next line depends on; breaks reached with equal states are + /// interchangeable from there on. + type State: Clone + PartialEq; + /// The state of the paragraph's first line. + fn first(&mut self) -> Self::State; + /// The width of the line of glyphs `pos..end` set from `state`, and the state of the line + /// after it; `Ok(None)` when the line can't be set (the frame is full), and [`Exhausted`] when + /// the search is to stop. + fn line(&mut self, state: &Self::State, pos: usize, end: usize) -> Result, Exhausted>; + /// Whether a line from `state` longer than `pos..end`, which [`Measure::line`] just measured, + /// may get more room than it. While it may, a line from `state` that is too long does not end + /// the reach of `state`. Lines from a state are all as wide by default. + fn may_widen(&mut self, _state: &Self::State, _pos: usize, _end: usize) -> bool { + false + } +} + +/// Line widths by line number: `width(k)` for line `k`, lines from `uniform_from` on all as wide. +struct ByLine<'a> { + width: &'a dyn Fn(usize) -> f64, + uniform_from: usize, +} + +impl Measure for ByLine<'_> { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, &line: &usize, _: usize, _: usize) -> Result, Exhausted> { + Ok(Some(((self.width)(line), (line + 1).min(self.uniform_from)))) + } +} + +struct Node { pos: usize, - /// Line number of the next line, clamped to `uniform_from`. - line: usize, + /// The measure of the next line (its line number, clamped to `uniform_from`, for [`compose`]). + state: S, fitness: u8, demerits: f64, /// Lines in a row ending with a hyphen up to this break (at most `hyphen_limit`, or 1 with no @@ -131,6 +171,19 @@ pub(crate) fn compose( cands: &[Breakpoint], flex: &[Flex], p: &Params, +) -> Option> { + compose_with(g, &mut ByLine { width, uniform_from: p.uniform_from }, start_credit, end_credit, cands, flex, p) +} + +/// [`compose`] with each line's width from `measure`, which follows where the lines above it went. +pub(crate) fn compose_with( + g: &[SGlyph], + measure: &mut M, + start_credit: &dyn Fn(usize) -> f64, + end_credit: &dyn Fn(usize) -> f64, + cands: &[Breakpoint], + flex: &[Flex], + p: &Params, ) -> Option> { let n = g.len(); if n == 0 { @@ -165,14 +218,14 @@ pub(crate) fn compose( all.push(Breakpoint { end: n, hyphen: 0.0 }); let (adj_demerits, hyphen_penalty) = if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) }; - let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphens: 0, prev: None }]; + let mut nodes = vec![Node { pos: 0, state: measure.first(), fitness: 1, demerits: 0.0, hyphens: 0, prev: None }]; let mut active: Vec = vec![0]; for bp in &all { let last = bp.end == n; let hyphen = bp.hyphen > 0.0; // `automatic` is true for a hyphenation point and false for a soft hyphen typed in the text. let automatic = hyphen && !bp.end.checked_sub(1).and_then(|k| g.get(k)).is_some_and(SGlyph::is_soft_hyphen); - let mut best: Vec<(usize, u8, usize, f64, usize)> = vec![]; // (line, fitness, hyphens, demerits, from) + let mut best: Vec<(M::State, u8, usize, f64, usize)> = vec![]; // (state, fitness, hyphens, demerits, from) let mut keep = Vec::with_capacity(active.len()); for &ai in &active { let Some(a) = nodes.get(ai) else { continue }; @@ -182,13 +235,18 @@ pub(crate) fn compose( // last glyph's advance, so a longer line from the same node is never narrower. let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) }; let natural = w[t] - w[a.pos] - credit - end + bp.hyphen; - let lw = width(a.line); + // A line that can't be set (no room left in the frame) ends this node's reach. + let Some((lw, next)) = measure.line(&a.state, a.pos, bp.end).ok()? else { continue }; // Adjustment ratio, badness and whether the line ends the active node's reach. - let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; - if ratio < -1.0 { - // Too long already; longer lines from this node only get worse. + let ratio = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p); + let Some(ratio) = ratio.filter(|&r| r >= -1.0 || r.is_nan()) else { + // Too long already; longer lines from this node only get worse, unless they may + // get more room (a taller line set lower down a shaped frame). + if measure.may_widen(&a.state, a.pos, bp.end) { + keep.push(ai); + } continue; - } + }; keep.push(ai); if automatic && p.hyphen_limit > 0 && a.hyphens >= p.hyphen_limit { continue; @@ -214,22 +272,21 @@ pub(crate) fn compose( d += adj_demerits; } let total = a.demerits + d; - let line = (a.line + 1).min(p.uniform_from); // With a limit, breaks reached with different runs of hyphenated lines stay apart. let hyphens = match (hyphen, p.hyphen_limit) { (false, _) => 0, (true, 0) => 1, (true, limit) => (a.hyphens + 1).min(limit), }; - match best.iter_mut().find(|b| b.0 == line && b.1 == fit && b.2 == hyphens) { + match best.iter_mut().find(|b| b.0 == next && b.1 == fit && b.2 == hyphens) { Some(b) if b.3 <= total => {} - Some(b) => *b = (line, fit, hyphens, total, ai), - None => best.push((line, fit, hyphens, total, ai)), + Some(b) => *b = (next, fit, hyphens, total, ai), + None => best.push((next, fit, hyphens, total, ai)), } } active = keep; - for (line, fitness, hyphens, demerits, from) in best { - nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphens, prev: Some(from) }); + for (state, fitness, hyphens, demerits, from) in best { + nodes.push(Node { pos: bp.end, state, fitness, demerits, hyphens, prev: Some(from) }); active.push(nodes.len() - 1); } if active.is_empty() || active.len() > MAX_ACTIVE { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cbe7aaf394..4f3566b97d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -31,6 +31,7 @@ mod flow; mod on_path; mod region; mod ruby; +mod shaped; mod vertical; pub(crate) use bidi::{is_rtl, para_bidi}; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 30a498257e..2eef2dd669 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -26,14 +26,18 @@ impl Metrics { pub(super) fn max(g: &[SGlyph]) -> Option { let mut it = g.iter(); let first = Self::of(it.next()?); - Some(it.fold(first, |m, g| Self { - asc: m.asc.max(g.ascent), - desc: m.desc.max(g.descent), - lead: m.lead.max(g.leading), - cap: m.cap.max(g.cap), - xh: m.xh.max(g.xh), - top: m.top.max(Self::of(g).top), - })) + Some(it.fold(first, |m, g| m.union(Self::of(g)))) + } + /// The larger of each of the metrics of `self` and `o`: those of a line holding both. + pub(super) fn union(self, o: Self) -> Self { + Self { + asc: self.asc.max(o.asc), + desc: self.desc.max(o.desc), + lead: self.lead.max(o.lead), + cap: self.cap.max(o.cap), + xh: self.xh.max(o.xh), + top: self.top.max(o.top), + } } /// Distance from the frame top to the first baseline. pub(super) fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 { @@ -67,6 +71,9 @@ pub(super) struct Pen<'r> { /// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's /// copies): see [`MAX_TOTAL_STEPS`]. pub(super) steps_left: std::rc::Rc>, + /// What is left of the every-line composer's search over the frame's paragraphs (shared by + /// this pen's copies): see [`super::shaped::Budget`]. + pub(super) search: std::rc::Rc, } /// The largest number of steps layout takes to move a line down a frame cell to find room for it: @@ -841,6 +848,7 @@ fn a_pen_out_of_baseline_steps_finds_no_room() { next_top: None, no_room_for: f64::INFINITY, steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)), + search: Default::default(), }; assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some()); assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none()); diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index aae91be1ee..13b09811bb 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -24,9 +24,15 @@ fn pushes_in(para: &ParaStyle) -> bool { /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; +/// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set: +/// rectangular cells without text wrap. +fn constant_width(regions: Option<&[Region]>) -> bool { + regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty())) +} + /// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, -/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the -/// greedy single-line composer. With `continued`, the paragraph continues from the frame before, +/// in any frame shape, with or without text wrap); `None` falls back to the greedy single-line +/// composer. With `continued`, the paragraph continues from the frame before, /// and its first composed line is not its first line. `line_end` is how much narrower a line /// ending with each glyph is set ([`ParagraphData::line_end`]). #[allow(clippy::too_many_arguments)] @@ -66,14 +72,11 @@ fn compose_para( let m = Metrics::of(&sg[0]); // Line widths are independent of the breaks when every line has the same metrics, or when // a line's width does not depend on how far down the frame it is set: rectangular cells - // without text wrap. Then a paragraph whose characters differ in line metrics (type size, - // font, leading, vertical scale, an inline graphic taller than the text) is composed too. In - // other shapes, or with text wrap, a taller line can move the lines below it to where the - // frame is narrower, so such a paragraph is broken line by line. - let plain = pen.regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty())); - if !plain && sg.iter().any(|g| Metrics::of(g) != m) { - return None; - } + // without text wrap. In other shapes, or with text wrap, a paragraph whose characters differ + // in line metrics (type size, font, leading, vertical scale, an inline graphic taller than + // the text) can have a taller line move the lines below it to where the frame is narrower: + // each line the composer tries is then placed after the lines it follows (`shaped::Shaped`). + let shaped = !constant_width(pen.regions) && sg.iter().any(|g| Metrics::of(g) != m); let total: f64 = sg.iter().map(|g| g.adv).sum(); // The same rule as the line loop below: an opening bracket starting a wrapped line gives up // the space before it. @@ -86,7 +89,7 @@ fn compose_para( let mut acc = 0.0; // A line's width and placement depend on the sum of its indents alone, which the mirrored // indents of a right-to-left list ([`line_indents`]) keep. - while widths.len() < sg.len() { + while !shaped && widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed @@ -103,7 +106,11 @@ fn compose_para( break; } } - let last = *widths.last()?; + let last = match widths.last() { + Some(&last) => last, + None if shaped => 0.0, + None => return None, + }; let width = |k: usize| widths.get(k).copied().unwrap_or(last); // Lines from `uniform_from` on are interchangeable (same width). let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1); @@ -119,6 +126,14 @@ fn compose_para( let (strict, loose) = (1.0, f64::INFINITY); // A line's last character is set narrower, as the line loop sets it. let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); + if shaped { + // Both passes share the placements tried and one search budget. + let mirror = mirrors_indents(para, rtl, cx.vertical); + let mut measure = super::shaped::Shaped::new(sg, line_end, para, &start_credit, continued, mirror, pen); + let mut pass = |p: &Params| crate::composer::compose_with(sg, &mut measure, &start_credit, &end_credit, &cands, flex, p); + let lines = pass(¶ms(strict)); + return lines.or_else(|| pass(¶ms(loose))); + } let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p); pass(¶ms(strict)).or_else(|| pass(¶ms(loose))) } @@ -142,7 +157,7 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { /// the right and the right indent from the left: the bullet of a right-to-left list hangs at the /// right, and the lines it wraps to line up with the text after the bullet. A list level past the /// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it. -fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { +pub(super) fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 }; if mirror { (para.right_indent, start) } else { (start, para.right_indent) } } @@ -162,6 +177,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg next_top: None, no_room_for: f64::INFINITY, steps_left: std::rc::Rc::new(std::cell::Cell::new(super::breaking::MAX_TOTAL_STEPS)), + search: Default::default(), }; let markers = list_markers(t, paras); for (pi, pr) in paras.iter().enumerate() { @@ -392,6 +408,12 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } // The line's first word, as narrow as the line sets it at both ends. let word = word_width(sg, *i, line_end, para.kinsoku); + // A composed line in a frame whose lines are not all as wide is placed at its own tallest + // metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height. + let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est), + _ => est, + }; // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { diff --git a/crates/text/src/layout/shaped.rs b/crates/text/src/layout/shaped.rs new file mode 100644 index 0000000000..72f6ed8733 --- /dev/null +++ b/crates/text/src/layout/shaped.rs @@ -0,0 +1,273 @@ +//! Every-line composition where a line's width depends on the lines above it: area type in a +//! shape other than a rectangle, or with text wrap, whose characters differ in line metrics (type +//! size, font, leading, vertical scale, an inline graphic taller than the text). A taller line +//! moves the lines below it down, to where the frame may be narrower or a wrap object splits the +//! line. Each line the composer tries is placed from where the lines above it left the pen, at its +//! own tallest metrics, as the line loop in [`super::flow`] places a composed line: the span it +//! gets is the room across its whole height. + +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + +use super::breaking::{Metrics, Pen, word_width}; +use super::flow::line_indents; +use super::{ParaStyle, SGlyph}; +use crate::composer::{Exhausted, Measure}; + +/// The most lines the composer tries to place for one paragraph before it stops and the paragraph +/// is broken line by line. +const MAX_PROBES: usize = 1_000_000; + +/// The most baseline steps placing those lines takes in all (see [`super::breaking::MAX_TOTAL_STEPS`]). +const MAX_STEPS: usize = 2_000_000; + +/// The most lines the composer tries to place for all the paragraphs of one frame's layout. +const MAX_LAYOUT_PROBES: usize = 2 * MAX_PROBES; + +/// The most baseline steps placing them takes over all the paragraphs of one frame's layout. +const MAX_LAYOUT_STEPS: usize = 2 * MAX_STEPS; + +/// What is left of the composer's search over one frame's layout: the lines it may still try to +/// place and the baseline steps placing them may still take. Every paragraph's search takes what +/// it spends from it, and stops when it runs out, or at its own limits of [`MAX_PROBES`] and +/// [`MAX_STEPS`]. It is separate from the layout's own steps ([`Pen::steps_left`]), which the +/// search leaves to the line loop. +pub(super) struct Budget { + probes: Cell, + steps: Cell, +} + +impl Budget { + /// A budget of `probes` lines to try and `steps` baseline steps. + pub(super) fn new(probes: usize, steps: usize) -> Self { + Self { probes: Cell::new(probes), steps: Cell::new(steps) } + } + + /// The lines left to try and the baseline steps left. + #[cfg(test)] + pub(super) fn left(&self) -> (usize, usize) { + (self.probes.get(), self.steps.get()) + } +} + +impl Default for Budget { + fn default() -> Self { + Self::new(MAX_LAYOUT_PROBES, MAX_LAYOUT_STEPS) + } +} + +/// Where a line starts: glyph `pos`, with the pen where the lines above it left it. +struct Start<'r> { + pos: usize, + /// The paragraph's first line (it takes the first-line indent). + first: bool, + pen: Pen<'r>, + /// The line placed at each of the metrics it was tried with: its width and the pen after it, + /// or `None` when the frame has no room for it. + placed: Vec<(Metrics, Option<(f64, Pen<'r>)>)>, + /// The metrics of glyphs `pos..to`, measured so far. + to: usize, + m: Option, +} + +/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line, +/// and the pen's cell, baselines, pending space, narrowest word without room and further spans. +type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>); + +fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key { + ( + pos, + first, + pen.ri, + pen.prev.map(f64::to_bits), + pen.next_top.map(f64::to_bits), + pen.pending.to_bits(), + pen.no_room_for.to_bits(), + pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(), + ) +} + +/// What placing a line reads from the paragraph. +struct Para<'a> { + sg: &'a [SGlyph], + /// How much narrower a line ending with each glyph is set (`ParagraphData::line_end`). + line_end: &'a [f64], + para: &'a ParaStyle, + /// The width a line starting with each glyph gives back (Mojikumi's flush opening bracket). + start_credit: &'a dyn Fn(usize) -> f64, + mirror: bool, + /// The baseline steps left to the search. + steps: Rc>, +} + +/// The measures of a paragraph's lines in a frame where they depend on the lines above. +pub(super) struct Shaped<'a, 'r> { + para: Para<'a>, + continued: bool, + pen: Pen<'r>, + starts: Vec>, + known: HashMap, + /// The lines tried so far, and the most this search may try. + probes: usize, + max_probes: usize, + /// The baseline steps this search started with. + max_steps: usize, + /// The layout's budget, which takes what the search spent when it ends. + budget: Rc, + /// The tallest metrics of glyphs `k..` for each `k`, `None` past the last glyph. + rest: Vec>, +} + +impl<'a, 'r> Shaped<'a, 'r> { + /// The measures of the lines of glyphs `sg` of paragraph `para`, set from `pen`. `line_end` + /// and `start_credit` are how much narrower a line ending with each glyph is set and how much + /// a line starting with it gives back; `continued`: the paragraph's first line is in the + /// frame before; `mirror`: its indents are mirrored. The search places its lines with a copy + /// of `pen` and takes its baseline steps from the layout's search budget ([`Pen::search`]), + /// so the frame's layout keeps its own steps. + pub(super) fn new( + sg: &'a [SGlyph], + line_end: &'a [f64], + para: &'a ParaStyle, + start_credit: &'a dyn Fn(usize) -> f64, + continued: bool, + mirror: bool, + pen: &Pen<'r>, + ) -> Self { + let mut pen = pen.clone(); + let budget = Rc::clone(&pen.search); + let max_steps = budget.steps.get().min(pen.steps_left.get()).min(MAX_STEPS); + let steps = Rc::new(Cell::new(max_steps)); + pen.steps_left = Rc::clone(&steps); + let mut rest = vec![None; sg.len() + 1]; + for (k, g) in sg.iter().enumerate().rev() { + let m = Metrics::of(g); + rest[k] = Some(rest[k + 1].map_or(m, |r: Metrics| r.union(m))); + } + Self { + para: Para { sg, line_end, para, start_credit, mirror, steps }, + continued, + pen, + starts: vec![], + known: HashMap::new(), + probes: 0, + max_probes: budget.probes.get().min(MAX_PROBES), + max_steps, + budget, + rest, + } + } + + /// The start of a line at glyph `pos` with `pen`: an existing one when its placement is the same. + fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>) -> usize { + let k = key(pos, first, &pen); + if let Some(&id) = self.known.get(&k) { + return id; + } + self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None }); + let id = self.starts.len() - 1; + self.known.insert(k, id); + id + } +} + +impl Measure for Shaped<'_, '_> { + type State = usize; + + fn first(&mut self) -> usize { + let pen = self.pen.clone(); + self.start(0, !self.continued, pen) + } + + fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result, Exhausted> { + if self.probes >= self.max_probes { + return Err(Exhausted); + } + self.probes += 1; + let Some(start) = self.starts.get_mut(id) else { return Ok(None) }; + let Some((width, pen)) = line(&self.para, start, pos, end)? else { return Ok(None) }; + Ok(Some((width, self.start(end, false, pen)))) + } + + /// A longer line is placed as this one is, and gets the same room, unless a glyph after it + /// changes the line's tallest metrics. + fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool { + let Some(start) = self.starts.get(id) else { return false }; + let m = if start.pos == pos && start.to == end { start.m } else { self.para.sg.get(pos..end).and_then(Metrics::max) }; + match (m, self.rest.get(end).copied().flatten()) { + (Some(m), Some(rest)) => m.union(rest) != m, + _ => false, + } + } +} + +/// The search's lines tried and baseline steps spent come off the layout's budget, so the +/// paragraphs of one frame share it. +impl Drop for Shaped<'_, '_> { + fn drop(&mut self) { + let spent = self.max_steps.saturating_sub(self.para.steps.get()); + self.budget.steps.set(self.budget.steps.get().saturating_sub(spent)); + self.budget.probes.set(self.budget.probes.get().saturating_sub(self.probes)); + } +} + +/// The width of the line of glyphs `pos..end` from `start`, and the pen after it; `None` when +/// the frame has no room for it. +fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result)>, Exhausted> { + if start.pos != pos || end <= pos { + return Ok(None); + } + // The line's tallest metrics, measured on from the glyphs already measured. + if end < start.to { + start.m = p.sg.get(pos..end).and_then(Metrics::max); + start.to = end; + } else if end > start.to { + let more = p.sg.get(start.to..end).and_then(Metrics::max); + start.m = match (start.m, more) { + (Some(m), Some(more)) => Some(m.union(more)), + (m, more) => m.or(more), + }; + start.to = end; + } + let Some(m) = start.m else { return Ok(None) }; + if let Some((_, placed)) = start.placed.iter().find(|(at, _)| *at == m) { + return Ok(placed.clone()); + } + let placed = place(p, start, m)?; + start.placed.push((m, placed.clone())); + Ok(placed) +} + +/// The line from `start` placed with metrics `m`: its width and the pen after it, as the line loop +/// places and settles it; `None` when no frame cell has room for it. +fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result)>, Exhausted> { + let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror); + let word = word_width(p.sg, start.pos, p.line_end, p.para.kinsoku) - (p.start_credit)(start.pos); + let mut pen = start.pen.clone(); + loop { + match pen.place(m, ind_l, ind_r, word) { + Some((baseline, x0, x1, _)) if baseline.is_finite() && (x1 - x0).is_finite() => { + if baseline + m.desc > pen.bottom() + 0.01 { + // A further span of the line above, too low for this line: the next row or column. + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + pen.queued.clear(); + continue; + } + pen.pending = 0.0; + pen.settled(baseline, m); + // Further spans of this line band share the settled baseline. + for queued in &mut pen.queued { + queued.0 = baseline; + } + return Ok(Some((x1 - x0 - ind_l - ind_r, pen))); + } + Some(_) => return Ok(None), + // Out of steps: the frame may still have room further down. + None if p.steps.get() == 0 => return Err(Exhausted), + None => return Ok(None), + } + } +} diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 110c257c46..9195764211 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -533,6 +533,147 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } +/// A line measure for `compose_with`: every line is `width` wide, its state is the glyph it starts +/// at, and the search stops once `calls` lines were measured. +struct Measured { + width: f64, + calls: usize, + /// A line from a start past this one finds the frame full. + full_after: usize, +} + +impl crate::composer::Measure for Measured { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, &start: &usize, _: usize, end: usize) -> Result, crate::composer::Exhausted> { + if self.calls == 0 { + return Err(crate::composer::Exhausted); + } + self.calls -= 1; + Ok((start <= self.full_after).then_some((self.width, end))) + } +} + +/// The every-line composer with a line measure that follows the lines above: it breaks as with +/// fixed widths when the widths are the same, finds no composition when the frame is full before +/// the paragraph ends, and gives up when the measure's search stops. +#[test] +fn composition_with_a_measure_from_the_lines_above() { + let text = "가나다 라마바"; + let glyphs = korean(text, false); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let with = |calls: usize, full_after: usize| { + let mut measure = Measured { width: 50.0, calls, full_after }; + crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms) + }; + let fixed = compose(&glyphs, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms); + assert_eq!(fixed, Some(vec![(4, false), (7, false)])); + assert_eq!(with(usize::MAX, usize::MAX), fixed); + assert_eq!(with(usize::MAX, 0), None, "no room for a second line"); + assert_eq!(with(1, usize::MAX), None, "the search stopped"); +} + +/// A line measure for `compose_with` whose lines are `narrow` wide, and `wide` wide when they reach +/// past glyph `from` (a line made taller there, and set lower in a frame that widens). +struct Widening { + narrow: f64, + wide: f64, + from: usize, +} + +impl crate::composer::Measure for Widening { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, _: &usize, _: usize, end: usize) -> Result, crate::composer::Exhausted> { + Ok(Some((if end > self.from { self.wide } else { self.narrow }, end))) + } + fn may_widen(&mut self, _: &usize, _: usize, end: usize) -> bool { + end <= self.from + } +} + +/// A line too long for its room does not end the composer's search from its start while a longer +/// line may get more room: the longer line is tried and set. +#[test] +fn composition_tries_a_longer_line_that_gets_more_room() { + let text = "가나다 라마바"; + let glyphs = korean(text, false); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + // "가나다" is 30 wide and does not fit 25; the whole paragraph reaches past glyph 4 and fits 100. + let mut measure = Widening { narrow: 25.0, wide: 100.0, from: 4 }; + let lines = crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms); + assert_eq!(lines, Some(vec![(7, false)])); +} + +/// The every-line composer's searches for the paragraphs of one frame share one budget: each takes +/// the lines it tried and the baseline steps it spent from it, the layout's own steps stay as they +/// were, and once the budget runs out the next paragraph's search stops (it is broken line by line). +#[test] +fn shaped_composition_searches_share_the_frame_budget() { + use super::shaped::{Budget, Shaped}; + use std::cell::Cell; + use std::rc::Rc; + let text = "aa bb cc dd ee ff gg hh ii jj"; + let mut glyphs = full_width(text); + glyphs[3].ascent = 30.0; + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + // 60 pt wide down to 40 pt, and 30 pt wide below. + let frame = BezPath::from_vec(vec![ + PathEl::MoveTo((0.0, 0.0).into()), + PathEl::LineTo((60.0, 0.0).into()), + PathEl::LineTo((60.0, 40.0).into()), + PathEl::LineTo((30.0, 40.0).into()), + PathEl::LineTo((30.0, 400.0).into()), + PathEl::LineTo((0.0, 400.0).into()), + PathEl::ClosePath, + ]); + let regions = region::Region::cells(&frame, &LayoutOptions::default(), &[]); + let para = ParaStyle::default(); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let pen = |search: Rc| breaking::Pen { + regions: Some(®ions), + ri: 0, + prev: None, + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + model: LeadingModel::default(), + next_top: None, + no_room_for: f64::INFINITY, + steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)), + search, + }; + let compose = |pen: &breaking::Pen<'_>| { + let mut measure = Shaped::new(&glyphs, &[], ¶, &|_| 0.0, false, false, pen); + crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms) + }; + // One paragraph's search, with room to spare. + let one = pen(Rc::new(Budget::default())); + let before = one.search.left(); + let lines = compose(&one); + assert!(lines.is_some()); + let after = one.search.left(); + let (probes, steps) = (before.0 - after.0, before.1 - after.1); + assert!(probes > 0 && steps > 0, "{probes} lines tried, {steps} steps"); + assert_eq!(one.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps"); + // A budget of three such searches: three paragraphs are composed, the fourth is not. + let three = pen(Rc::new(Budget::new(3 * probes, 3 * steps))); + for k in 0..3 { + assert_eq!(compose(&three), lines, "paragraph {k}"); + } + assert_eq!(three.search.left(), (0, 0)); + assert_eq!(compose(&three), None, "the budget ran out"); + assert_eq!(three.search.left(), (0, 0)); + assert_eq!(three.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps"); +} + /// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean /// Line Break Anywhere on). fn korean(text: &str, anywhere: bool) -> Vec { diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 4145c8937f..0146871265 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -334,14 +334,14 @@ fn composer_x_mixed_sizes() { assert_eq!(x.line, 0); assert!(l.lines[0].ascent > l.lines[1].ascent + 1.0, "{:?}", l.lines); // A rectangle that wraps around an object, and a frame that isn't a rectangle (a corner cut), - // both far below the text, break mixed sizes line by line and still break uniform sizes with - // the every-line composer. + // both far below the text, break mixed sizes and uniform sizes alike with the every-line + // composer. let below = PathData::from_bezpath(&Rect::new(0.0, 180.0, 20.0, 190.0).to_path(0.1)); - for (x, want) in [(&wide, every), (&big, greedy)] { + for x in [&wide, &big] { let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0)); t.wrap = vec![vectorcraft_doc::WrapShape { path: below.clone(), wrap: Default::default() }]; t.para.composer = Composer::EveryLine; - assert_eq!(line_texts(&t, &layout(db(), &t)), want, "wrap, {}", x.size); + assert_eq!(line_texts(&t, &layout(db(), &t)), every, "wrap, {}", x.size); } let cut = kurbo::BezPath::from_vec(vec![ kurbo::PathEl::MoveTo((0.0, 0.0).into()), @@ -351,14 +351,176 @@ fn composer_x_mixed_sizes() { kurbo::PathEl::LineTo((0.0, 200.0).into()), kurbo::PathEl::ClosePath, ]); - for (x, want) in [(&wide, every), (&big, greedy)] { + for x in [&wide, &big] { let mut t = area(runs(x.clone()), Rect::ZERO); t.kind = TextKind::Area { frame: PathData::from_bezpath(&cut) }; t.para.composer = Composer::EveryLine; - assert_eq!(line_texts(&t, &layout(db(), &t)), want, "cut corner, {}", x.size); + assert_eq!(line_texts(&t, &layout(db(), &t)), every, "cut corner, {}", x.size); } } +/// Text with the word `X` set in `x` and the rest in 12 pt. +fn with_x(x: CharStyle) -> Vec { + let st = sans(12.0); + vec![ + run("Destroy all creatures with toughness ", st.clone()), + run("X", x), + run(" or less. Then draw a card for each creature destroyed this way.", st), + ] +} + +/// The width the text of [`with_x`] is set in. +fn x_width() -> f64 { + natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0 +} + +#[test] +fn composer_x_mixed_sizes_in_a_stepped_frame() { + // A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes + // the line it is on reach below the step, where the frame is narrow: the every-line composer + // breaks the paragraph with each line's measure where its height sets it, not as the + // single-line composer does, and every line keeps within its room. + let w = x_width(); + let set = |x: CharStyle, composer: Composer| { + let mut t = stepped(w, w * 0.5, 40.0, 300.0); + t.runs = with_x(x); + t.para.composer = composer; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines); + (line_texts(&t, &l), l) + }; + let (every, l) = set(sans(36.0), Composer::EveryLine); + assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each", "creature destroyed", "this way."]); + // The 36 pt line reaches below the step and is set at the narrow measure. + let tall = &l.lines[1]; + assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines); + let (greedy, _) = set(sans(36.0), Composer::SingleLine); + assert_ne!(every, greedy); + // Uniform sizes keep the breaks they had. + let (uniform, _) = set(sans(12.0), Composer::EveryLine); + assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]); +} + +#[test] +fn composer_x_mixed_sizes_around_text_wrap() { + // A wrap object across the middle of the frame from 40 pt down splits the lines beside it in + // two. The line of the 36 pt word, which starts with a 12 pt word, falls beside it, and the + // every-line composer measures each of the two spans across the line's whole height: no + // glyph runs into the object. + let w = x_width(); + let object = Rect::new(w * 0.4, 40.0, w * 0.6, 70.0); + let set = |composer: Composer| { + let mut runs = with_x(sans(36.0)); + // A no-break space keeps the 36 pt word on the line of the 12 pt word before it. + runs[0] = run("Destroy all creatures with toughness\u{a0}", sans(12.0)); + let mut t = area(runs, Rect::new(0.0, 0.0, w, 300.0)); + t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + t.para.composer = composer; + let l = layout(db(), &t); + (line_texts(&t, &l), l, t) + }; + let (every, l, t) = set(Composer::EveryLine); + assert_eq!( + every, + ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature", "destroyed this", "way."] + ); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{:?}", l.lines); + // Two spans share each baseline beside the object. + assert_eq!(l.lines[1].baseline, l.lines[2].baseline); + assert!(l.lines[1].ascent > 30.0 && l.lines[2].avail.0 > object.x1, "{:?}", l.lines); + let text = t.plain_text(); + let hits = l.glyphs.iter().filter(|g| { + let li = &l.lines[g.line]; + let across = li.baseline - li.ascent < object.y1 && li.baseline + li.descent > object.y0; + across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 + }); + assert_eq!(hits.count(), 0, "{:?}", l.lines); + let (greedy, _, _) = set(Composer::SingleLine); + assert_ne!(every, greedy); +} + +#[test] +fn composer_x_mixed_sizes_in_a_frame_that_widens() { + // A frame as wide as "Destroy all" down to 20 pt and as wide as the first line of + // `composer_x_mixed_sizes_in_a_stepped_frame` below. The second line is too long for the narrow + // part until it takes in the 48 pt word, which sets it lower, where the frame is wide: the + // every-line composer tries that longer line and sets it there. + let w = x_width(); + let narrow = natural_width("Destroy all", 12.0) + 2.0; + let set = |composer: Composer| { + let mut t = stepped(narrow, w, 20.0, 300.0); + t.runs = with_x(sans(48.0)); + t.para.composer = composer; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines); + (line_texts(&t, &l), l) + }; + let (every, l) = set(Composer::EveryLine); + assert_eq!(every, ["Destroy all", "creatures with toughness X or less.", "Then draw a card for each creature", "destroyed this way."]); + let tall = &l.lines[1]; + assert!(tall.ascent > 40.0 && tall.baseline - tall.ascent > 20.0 && tall.avail.1 > narrow + 1.0, "{:?}", l.lines); + let (greedy, _) = set(Composer::SingleLine); + assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]); +} + +#[test] +fn composer_x_mixed_sizes_overflow_a_shaped_frame() { + // A stepped frame too short for the paragraph: the lines that fit are set, in order, each once + // and within its room, and the frame overflows. + let w = x_width(); + let mut t = stepped(w, w * 0.5, 40.0, 75.0); + t.runs = with_x(sans(36.0)); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + assert!(l.overflow && l.lines.len() >= 2, "{:?}", l.lines); + assert_eq!(l.lines[0].start, 0); + assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines); + assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines); + assert!(l.lines.iter().all(|li| li.baseline + li.descent <= 75.0 + 0.01), "{:?}", l.lines); +} + +#[test] +fn composer_x_mixed_sizes_long_paragraph_in_a_shaped_frame() { + // A long paragraph of mixed sizes in a stepped frame is laid out whole, its lines in order and + // each within its room, whether the composer's search finishes or stops at its limits. + let w = x_width(); + let mut t = stepped(w, w * 0.5, 40.0, 100_000.0); + t.runs = (0..150).flat_map(|_| with_x(sans(36.0))).collect(); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + assert!(!l.overflow, "{} lines", l.lines.len()); + assert_eq!(l.lines.last().map(|li| li.end), Some(t.plain_text().len())); + assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start), "lines out of order"); + assert!(past_their_room(&t, &l).is_empty()); +} + +/// A frame `w` wide down to `y`, and `narrow` wide below it. +fn stepped(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { + let frame = kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((0.0, 0.0).into()), + kurbo::PathEl::LineTo((w, 0.0).into()), + kurbo::PathEl::LineTo((w, y).into()), + kurbo::PathEl::LineTo((narrow, y).into()), + kurbo::PathEl::LineTo((narrow, bottom).into()), + kurbo::PathEl::LineTo((0.0, bottom).into()), + kurbo::PathEl::ClosePath, + ]); + let mut t = area(vec![], Rect::ZERO); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t +} + +/// The lines of `l` whose glyphs reach past either end of the room they were set in. +fn past_their_room(t: &TextObject, l: &TextLayout) -> Vec { + let text = t.plain_text(); + let past = |i: usize| { + let (a, b) = l.lines[i].avail; + let mut glyphs = l.glyphs.iter().filter(|g| g.line == i && !text[g.byte..].starts_with(char::is_whitespace)); + glyphs.any(|g| g.origin.x < a - 0.01 || g.origin.x + g.advance > b + 0.01) + }; + (0..l.lines.len()).filter(|&i| past(i)).collect() +} + #[test] fn composer_x_mixed_line_metrics() { // In a rectangle the every-line composer also breaks a paragraph whose characters differ in diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..921ccdd6b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9905,8 +9905,12 @@ justified text gets even word spacing, ragged text (left, centre or right aligne that fits to the next line to avoid a lone short word on the last line or a full line next to a short one. Single-line fills each line as far as it goes. Point type and type on a path have no line width, so both composers give the same result there. A paragraph whose characters differ in line metrics (type size, font, leading or vertical -scale, or an inline graphic taller than the text) is composed with Every-line in rectangular area type, rows and -columns included; in area type of another shape, or with text wrap, it falls back to Single-line. +scale, or an inline graphic taller than the text) is composed with Every-line in area type of any shape, rows, +columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured +where the lines above it put it, across the line's own height, so a taller line that reaches where the frame is +narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has +no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and +2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame). The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..c6681b6cba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1225,7 +1225,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). - The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. + The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.