diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2405ad55f..f9db3de68 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -23,6 +23,22 @@ use crate::shape::{ }; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; +mod bidi; +mod breaking; +mod driver; +mod flow; +mod on_path; +mod region; +mod ruby; +mod vertical; + +pub(crate) use bidi::{is_rtl, para_bidi}; +use breaking::tab_advance; +#[cfg(test)] +use breaking::{break_line, candidates}; +pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; +use vertical::{stands_upright, tate_chu_yoko, upright_cell, upright_centre, whole_character_widths}; + const EPS: f64 = 1e-6; struct Ctx<'a> { @@ -287,1409 +303,6 @@ impl Ctx<'_> { } } -/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). -pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { - let a = &t.area; - let opts = LayoutOptions { - rows: a.rows, - columns: a.columns, - gutter: a.gutter, - inset: a.inset, - first_baseline: a.first_baseline, - first_baseline_min: a.first_baseline_min, - vertical_align: a.vertical_align, - fit: a.fit, - ..LayoutOptions::default() - }; - layout_with(db, t, &opts) -} - -/// Area type in a frame too short for its first line draws nothing (type that doesn't fit its frame -/// isn't drawn). For importers whose source shows that line: the frame is made taller, from its -/// top, by what the first line needs. Whether it was. -pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { - let TextKind::Area { frame } = &t.kind else { return false }; - let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; - if !layout(db, t).lines.is_empty() { - return false; - } - // The height of its first line: the line's as point type. - let point = TextObject { kind: TextKind::Point, ..t.clone() }; - let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false }; - // (A little more than it, for the rounding of the layout.) - let by = (need + 0.05) / b.height(); - if !(by.is_finite() && by > 1.0) { - return false; - } - let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); - let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); - // Something else keeps the line out (a frame narrower than a letter): as the source has it. - if layout(db, t).lines.is_empty() { - t.kind = short; - return false; - } - true -} - -/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a -/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's -/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift -/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet. -pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> { - let mut out = vec![]; - if layout.on_path || layout.vertical { - return out; - } - let glyphs = &layout.glyphs; - let mut i = 0; - while let Some(g) = glyphs.get(i) { - // The stretch: the glyphs of this run on this line, in a row. A list marker has no - // underline or strikethrough. - let marker = layout.marker_of(i).is_some(); - let end = glyphs - .iter() - .enumerate() - .skip(i) - .position(|(k, h)| h.run != g.run || h.line != g.line || layout.marker_of(k).is_some() != marker) - .map_or(glyphs.len(), |n| i + n); - let stretch = glyphs.get(i..end).unwrap_or_default(); - i = end.max(i + 1); - if marker { - continue; - } - let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue }; - if !(style.underline || style.strikethrough) { - continue; - } - let Some(face) = db.face_by_id(g.font_id) else { continue }; - let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| { - let (s, e) = (h.origin.x, h.origin.x + h.advance); - (a.min(s).min(e), b.max(s).max(e)) - }); - for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] { - if !on { - continue; - } - // Font units run y down in the glyph's transform: the bar's top is at -offset. - let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y); - let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom)); - if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() { - out.push((g.run, bar.to_path(0.1))); - } - } - } - out -} - -/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). -const SHRINK_PASSES: usize = 11; - -/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, -/// fit, composer, OpenType features). -pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { - let first = layout_once(db, t, opts); - match (opts.fit.min_scale(), &t.kind) { - (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first), - _ => first, - } -} - -/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which -/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size -/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, -/// still overflowing. None (keep the full-size layout) when the text has no usable size. -fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { - // Exact size edits after object scaling may store a local size below 0.1. Fit in the - // same document points as the Character controls, independent of that representation. - let points = t.style_scale()?.points; - let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; - if !(min.is_finite() && min < 1.0) { - return None; - } - let tenths = size * 10.0; - if !tenths.is_finite() { - return None; - } - // Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow - // unless it is below the full size; `lo` is the smallest allowed. - let mut lo = (tenths * min).ceil().max(1.0); - let mut hi = tenths.floor(); - if hi < lo { - hi = lo; - } - let at = |k: f64| -> TextLayout { - let f = (k / tenths).clamp(min, 1.0); - let mut l = layout_once(db, &scaled(t, f), opts); - l.fit_scale = f; - l - }; - let mut passes = 0; - let mut best: Option = None; - // `hi` itself may fit when the size isn't a whole number of tenths. - if hi < tenths && hi > lo { - let l = at(hi); - passes += 1; - if !l.overflow { - return Some(l); - } - } - // Invariant: everything at `hi` or above overflows; `lo` fits or is the floor. - while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES { - let mid = (lo + (hi - lo) * 0.5).floor(); - let l = at(mid); - passes += 1; - if l.overflow { - hi = mid; - } else { - lo = mid; - best = Some(l); - } - } - // `best` is the layout at `lo` once `lo` has moved; at the floor it still needs laying out. - Some(best.unwrap_or_else(|| at(lo))) -} - -/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own -/// shift included (auto leading and the art's size follow the size; paragraph spacing stays). -/// Shrink Text to Fit lays `t` out as `scaled(t, f)`, where `f` is the layout's -/// [`TextLayout::fit_scale`], so the result holds the styles the text is drawn in. -pub fn scaled(t: &TextObject, f: f64) -> TextObject { - let mut s = t.clone(); - for r in &mut s.runs { - r.style.size *= f; - r.style.leading = r.style.leading.map(|l| l * f); - r.style.baseline_shift *= f; - if let Some(a) = &mut r.inline { - a.baseline_shift *= f; - } - } - s -} - -/// One layout pass, without fitting. -fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { - let text = t.plain_text(); - let mut runs = Vec::with_capacity(t.runs.len()); - let mut inlines = Vec::with_capacity(t.runs.len()); - let mut off = 0; - for r in &t.runs { - runs.push((off..off + r.text.len(), &r.style)); - // Only a well-formed inline run (one object replacement character) is a graphic. - inlines.push(r.inline.as_ref().filter(|_| crate::edit::is_inline_text(&r.text))); - off += r.text.len(); - } - let mut paras = Vec::new(); - let mut s = 0; - for (i, c) in text.char_indices() { - if c == '\n' { - paras.push(s..i); - s = i + 1; - } - } - paras.push(s..text.len()); - let vertical = t.vertical; - let is_on_path = matches!(&t.kind, TextKind::OnPath { .. }); - let vopts; - let opts = if vertical { - vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() }; - &vopts - } else { - opts - }; - let mut cx = Ctx { - on_path: is_on_path, - db, - text: &text, - runs, - inlines, - default: CharStyle::default(), - opts, - vertical, - system_layout: t.system_layout, - fractional_widths: t.fractional_widths, - out: TextLayout::default(), - }; - // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next - // one to the left). Point type's anchor is on the first column's centre line. Vertical path - // type stays on its path (each glyph turned across it in `emit`). - let line_xf = match &t.kind { - _ if !vertical => Affine::IDENTITY, - TextKind::OnPath { .. } => Affine::IDENTITY, - TextKind::Point => { - let first = cx.style_at(0); - let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre()); - Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN - } - _ => QUARTER_TURN, - }; - match &t.kind { - TextKind::Point => flow(&mut cx, ¶s, t, None), - TextKind::Area { frame } => { - let to_lines = line_xf.inverse(); - let wrap: Vec = if vertical { - t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect() - } else { - t.wrap.clone() - }; - let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap); - cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect(); - flow(&mut cx, ¶s, t, Some(®ions)); - if opts.vertical_align != VerticalAlign::Top { - align_vertically(&mut cx, ¶s, t, &mut regions); - } - } - TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), - } - // Ruby (furigana): placed over its base text in line space, before vertical type turns the - // lines into columns, so the same pass puts it above (right in vertical type) and widens the - // lines it sits on. Type on a path has no above/below; its ruby is left out for now. - if !is_on_path && !t.ruby.is_empty() { - ruby_layout(&mut cx, t); - } - if vertical && !is_on_path { - // Glyph origins, outlines and transforms to text space (lines stay in line space). - for g in &mut cx.out.glyphs { - g.outline.apply_affine(line_xf); - g.xf = line_xf * g.xf; - g.origin = line_xf * g.origin; - g.angle += std::f64::consts::FRAC_PI_2; - } - // Ruby glyphs are placed in line space too (upright, along the column, in the vertical - // pass); the same turn takes them to text space. - for g in &mut cx.out.ruby { - g.outline.apply_affine(line_xf); - g.xf = line_xf * g.xf; - g.origin = line_xf * g.origin; - g.angle += std::f64::consts::FRAC_PI_2; - } - for i in &mut cx.out.inlines { - i.xf = line_xf * i.xf; - i.bounds = line_xf.transform_rect_bbox(i.bounds); - } - cx.out.vertical = true; - cx.out.line_xf = line_xf; - } - finish_bounds(&mut cx.out); - cx.out -} - -/// System Layout on or Fractional Widths off: each character that takes space advances by a whole -/// number of points, at least one, so small type does not collapse onto itself. A zero advance (a -/// tate-chu-yoko continuation, a zero-width mark) stays zero. -fn whole_character_widths(glyphs: &mut [SGlyph]) { - for g in glyphs { - if g.adv.is_finite() && g.adv > 0.0 { - g.adv = g.adv.round().max(1.0); - } - } -} - -/// Tate-chu-yoko: a horizontal block one em of the column long, squeezed to the em when wider. -/// -/// Automatic: a run of two or three half-width digits (`10`月, `100`年). A single digit and a -/// longer number (`2026`) stay on their side. -/// -/// Manual (Character panel menu › Tate-chu-yoko): any characters marked `tateChuYoko`, including -/// one character, letters and numbers longer than three digits. Marked characters are taken -/// before the automatic digit rule. -/// -fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) { - let manual: Vec = g.iter().map(|gl| style(gl).1).collect(); - let digits: Vec = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect(); - let mut i = 0; - while i < g.len() { - if manual.get(i).copied().unwrap_or(false) { - let mut end = i + 1; - while manual.get(end).copied().unwrap_or(false) { - end += 1; - } - let em = style(&g[i]).0; - place_tcy_block(&mut g[i..end], em); - i = end; - continue; - } - if !digits.get(i).copied().unwrap_or(false) { - i += 1; - continue; - } - let mut end = i + 1; - while digits.get(end).copied().unwrap_or(false) && !manual.get(end).copied().unwrap_or(false) { - end += 1; - } - if (2..=3).contains(&(end - i)) { - let em = style(&g[i]).0; - place_tcy_block(&mut g[i..end], em); - } - i = end; - } -} - -/// Lay `g` across one em of the column. The first glyph takes the em; the rest share its cell. -fn place_tcy_block(g: &mut [SGlyph], em: f64) { - let width: f64 = g.iter().map(|gl| gl.adv).sum(); - let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 }; - let mut ink = 0.0; - for (k, gl) in g.iter_mut().enumerate() { - let adv = gl.adv; - gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze }); - gl.adv = if k == 0 { em } else { 0.0 }; - ink += adv; - } -} - -/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its -/// advance an em, give or take a tenth), the space that mojikumi can take off. -fn punct_half(g: &SGlyph, kind: Punct) -> Option { - // A glyph Proportional Metrics re-spaced has no empty half left to take off (#966). - if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional { - return None; - } - let em = g.face.units_per_em(); - ((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx) -} - -/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket, -/// comma or full stop followed by punctuation loses the space after it; an opening bracket after -/// another loses the space before it. Returns which glyphs lost the space after them (a line -/// ending in one takes nothing more off). -fn compress_punctuation(sg: &mut [SGlyph]) -> Vec { - let mut lost_after = vec![false; sg.len()]; - for j in 1..sg.len() { - let (before, after) = sg.split_at_mut(j); - let (Some(a), Some(b)) = (before.last_mut(), after.first_mut()) else { continue }; - if punct(b.ch).is_none() { - continue; - } - if let Some(h) = punct_half(a, Punct::Closing) { - a.adv -= h; - if let Some(l) = lost_after.get_mut(j - 1) { - *l = true; - } - } else if punct(a.ch) == Some(Punct::Opening) - && let Some(h) = punct_half(b, Punct::Opening) - { - b.adv -= h; - b.lead += h; - } - } - lost_after -} - -/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the -/// Japanese characters' size), with Line-end Punctuation Half Width. -const WAKAN_AKI: f64 = 0.25; - -/// A Japanese character for the space next to Latin text: kana and kanji (and full-width -/// letters, and the iteration and abbreviation marks 々 〆 〇), not punctuation or symbols, nor -/// Hangul (Korean sets a word space instead). -fn is_japanese_letter(c: char) -> bool { - if matches!(c, '々' | '〆' | '〇') { - return true; - } - is_cjk(c) - && !matches!( - c as u32, - 0x3000..=0x303F | 0x3130..=0x318F | 0xAC00..=0xD7AF | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 | 0xFFA0..=0xFFDC - ) - && c != '・' - && punct(c).is_none() -} - -/// A Latin letter or digit (or another script's letter), set proportionally. -fn is_latin_letter(c: char) -> bool { - c.is_alphanumeric() && !is_cjk(c) -} - -/// Mojikumi (JLREQ 3.2.2): a quarter em between a Japanese character and a Latin letter or digit, -/// either way round, added after the first of the two. Returns what each glyph got after it (taken -/// off again when the line ends there). -fn space_japanese_and_latin(sg: &mut [SGlyph]) -> Vec { - let mut added = vec![0.0; sg.len()]; - for j in 1..sg.len() { - let (before, after) = sg.split_at_mut(j); - let (Some(a), Some(b)) = (before.last_mut(), after.first()) else { continue }; - if a.tcy.is_some() || b.tcy.is_some() { - continue; - } - let japanese = if is_japanese_letter(a.ch) && is_latin_letter(b.ch) { - Some(&*a) - } else if is_latin_letter(a.ch) && is_japanese_letter(b.ch) { - Some(b) - } else { - None - }; - if let Some(g) = japanese { - let aki = WAKAN_AKI * g.face.units_per_em() * g.sx; - a.adv += aki; - if let Some(x) = added.get_mut(j - 1) { - *x = aki; - } - } - } - added -} - -/// The size of `g`'s em, in points. -fn glyph_em(g: &SGlyph) -> f64 { - g.face.units_per_em() * g.sy -} - -/// How far up (line space) Character Alignment `a` moves glyph `g` on a line whose largest em is -/// `line_em`: the glyph's em box top, centre or bottom onto the line's (the em box running from -/// its centre less half an em to its centre plus half an em above the baseline), or its ICF's top -/// or bottom (the em box's less the face's [`crate::IcfMargins`]; across a vertical line, its -/// right and left). Nothing on the Roman baseline, or for the line's largest characters. -/// -/// An inline graphic has its run's em (whatever its scale): it counts as one of the line's -/// largest characters only when its run's size is, and moves with the text of its run (its art -/// stays centred on that text's cap height). -fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64, vertical: bool) -> f64 { - use vectorcraft_doc::CharAlign; - let icf = || g.face.icf_margins(); - let k = match a { - CharAlign::RomanBaseline => return 0.0, - CharAlign::EmBoxTop => 0.5, - CharAlign::EmBoxCenter => 0.0, - CharAlign::EmBoxBottom => -0.5, - CharAlign::IcfTop => 0.5 - if vertical { icf().right } else { icf().top }, - CharAlign::IcfBottom => -0.5 + if vertical { icf().left } else { icf().bottom }, - }; - (g.face.ideographic_centre() + k) * (line_em - glyph_em(g)).max(0.0) -} - -/// The length an upright glyph takes down the column before tracking and justification: its -/// vertical advance (the font's vertical metrics), else its advance, at least one em. -fn upright_cell(g: &SGlyph) -> f64 { - match g.face.vertical_glyph(g.gid) { - Some((advance, _)) => advance * g.sy, - None => (g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx), - } -} - -/// Height above the baseline (line space) of the centre of an upright glyph's `cell`, the point it -/// turns about: from the font's vertical metrics, the cell hanging from the glyph's vertical origin; -/// else the centre of the ideographic em box ([`EM_CENTER`] of the size `em`). -fn upright_centre(g: &SGlyph, cell: f64, em: f64) -> f64 { - match g.face.vertical_glyph(g.gid) { - Some((_, origin)) => origin * g.sy - cell * 0.5, - None => EM_CENTER * em, - } -} - -/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs -/// from 0.12 em below the baseline to 0.88 em above it), for fonts without vertical metrics. -const EM_CENTER: f64 = 0.38; - -/// A quarter turn clockwise (y down), exact: line space → text space for vertical type. -const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); - -/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical -/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on -/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on -/// their side when the font has none (Unicode's Vertical_Orientation Tr). -/// Fullwidth !/? instead keep the nominal glyph upright (Tu fallback, UAX #50 §3.1): -/// . -fn stands_upright(g: &SGlyph) -> bool { - let c = g.ch as u32; - let upright = matches!(c, - 0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7 - | 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051 - | 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117 - | 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189 - | 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59 - | 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF - | 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7 - | 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF); - if !upright { - return false; - } - let needs_vertical_form = matches!(c, - 0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC - | 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B - | 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3); - // Without a vertical alternate the shaped glyph is the nominal one. - !needs_vertical_form || g.gid != g.face.glyph_for(g.ch) -} - -fn finish_bounds(out: &mut TextLayout) { - let xf = out.line_xf; - let mut b: Option = None; - let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r))); - for g in &out.glyphs { - if !g.outline.elements().is_empty() { - add(g.outline.bounding_box()); - } - if let Some((image, xf)) = g.placed_image() { - add(xf.transform_rect_bbox(image.rect())); - } - if let Some(b) = g.colour.as_ref().and_then(|c| c.geometric_bounds()) { - add(g.xf.transform_rect_bbox(b)); - } - } - // Ruby: covered by its line's ascent, but its ink can overhang sideways past the line. - for g in &out.ruby { - if !g.outline.elements().is_empty() { - add(g.outline.bounding_box()); - } - } - for i in &out.inlines { - add(i.bounds); - } - if !out.on_path { - for l in &out.lines { - add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent))); - } - } - out.bounds = b.unwrap_or_default(); -} - -/// Space left in each cell after flowing top-aligned: (space above the first line's ascent, space -/// below the last line's descent, number of distinct baselines). Cells without lines are `None`. -fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec> { - regions - .iter() - .enumerate() - .map(|(ri, r)| { - let mut top = f64::INFINITY; - let mut bottom = f64::NEG_INFINITY; - let mut baselines: Vec = vec![]; - for l in out.lines.iter().filter(|l| l.region == ri) { - top = top.min(l.baseline - l.ascent); - bottom = bottom.max(l.baseline + l.descent); - if !baselines.iter().any(|b| (b - l.baseline).abs() < 1e-6) { - baselines.push(l.baseline); - } - } - if baselines.is_empty() || !top.is_finite() || !bottom.is_finite() { - return None; - } - // Never negative: a full (or overflowing) cell stays where top alignment put it. - Some(((top - r.top()).max(0.0), (r.bottom() - bottom).max(0.0), baselines.len())) - }) - .collect() -} - -/// Area Type Options "Align" other than Top: move each cell's lines down. Rectangular cells -/// without text wrap shift their lines (centre: half the space left below the last line; -/// bottom: all of it; justify: the first line stays, each further line gets an equal share of it). -/// Other frames give lines different widths at different heights, so the text flows again with -/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a -/// pass that would push text out of the frame is undone and retried with half the step. -fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: &mut [Region]) { - let align = cx.opts.vertical_align; - let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()); - if plain { - let space = cell_space(&cx.out, regions); - // Each distinct baseline's index within its cell, for justify. - let mut seen: Vec> = vec![vec![]; regions.len()]; - let offs: Vec = cx - .out - .lines - .iter() - .map(|l| { - let Some(Some((_, below, n))) = space.get(l.region).copied() else { return 0.0 }; - match align { - VerticalAlign::Top => 0.0, - VerticalAlign::Center => below * 0.5, - VerticalAlign::Bottom => below, - VerticalAlign::Justify => { - let Some(s) = seen.get_mut(l.region) else { return 0.0 }; - let k = s.iter().position(|b| (b - l.baseline).abs() < 1e-6).unwrap_or_else(|| { - s.push(l.baseline); - s.len() - 1 - }); - if n > 1 { below * k as f64 / (n - 1) as f64 } else { 0.0 } - } - } - }) - .collect(); - for (l, d) in cx.out.lines.iter_mut().zip(&offs) { - l.baseline += d; - } - for g in &mut cx.out.glyphs { - let d = offs.get(g.line).copied().unwrap_or(0.0); - if d != 0.0 { - let m = Affine::translate((0.0, d)); - g.outline.apply_affine(m); - g.xf = m * g.xf; - g.origin.y += d; - } - } - // Inline art moves with its glyph's line. - for i in &mut cx.out.inlines { - let d = cx.out.glyphs.get(i.glyph).and_then(|g| offs.get(g.line)).copied().unwrap_or(0.0); - if d != 0.0 { - let m = Affine::translate((0.0, d)); - i.xf = m * i.xf; - i.bounds = m.transform_rect_bbox(i.bounds); - } - } - return; - } - let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end); - // How much of the measured space a pass takes up: halved after a pass that lost text. - let mut step = 1.0; - for _ in 0..8 { - let space = cell_space(&cx.out, regions); - let mut moved = false; - let saved: Vec<(f64, f64)> = regions.iter().map(|r| (r.shift, r.gap)).collect(); - for (r, s) in regions.iter_mut().zip(&space) { - let Some((above, below, n)) = *s else { continue }; - let (shift, gap) = match align { - VerticalAlign::Top => (r.shift, r.gap), - VerticalAlign::Center => (r.shift + (below - above) * 0.5 * step, r.gap), - VerticalAlign::Bottom => (r.shift + below * step, r.gap), - VerticalAlign::Justify if n > 1 => (r.shift, r.gap + below * step / (n - 1) as f64), - VerticalAlign::Justify => (r.shift, r.gap), - }; - let (shift, gap) = (shift.clamp(0.0, r.cell.height().max(0.0)), gap.clamp(0.0, r.cell.height().max(0.0))); - if (shift - r.shift).abs() > 0.25 || (gap - r.gap).abs() > 0.01 { - moved = true; - } - r.shift = shift; - r.gap = gap; - } - if !moved { - return; - } - let before = laid_out(&cx.out); - let prev = ( - std::mem::take(&mut cx.out.glyphs), - std::mem::take(&mut cx.out.lines), - std::mem::take(&mut cx.out.inlines), - std::mem::take(&mut cx.out.markers), - std::mem::replace(&mut cx.out.overflow, false), - ); - flow(cx, paras, t, Some(regions)); - if laid_out(&cx.out) < before { - // Text no longer fits (lines got narrower, or flow around a wrap object): undo the - // pass and try a smaller step. - (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.markers, cx.out.overflow) = prev; - for (r, (shift, gap)) in regions.iter_mut().zip(saved) { - r.shift = shift; - r.gap = gap; - } - step *= 0.5; - if step < 0.1 { - return; - } - } - } -} - -/// A flattened text wrap shape: (polygons, offset, invert). -type FlatWrap = (Vec>, f64, bool); - -/// One cell of a flattened area-type frame (the whole frame, or one row/column of it). -struct Region { - /// The cell (frame bounds, or a grid cell of them). - cell: Rect, - /// Inset applied inside the frame edges. - inset: f64, - polys: std::sync::Arc<[Vec]>, - /// The frame is its own bounding rectangle (spans need no polygon intersection). - rect: bool, - /// Text Wrap shapes. - wraps: std::sync::Arc<[FlatWrap]>, - /// Vertical alignment: how far below the top-aligned position the first line starts. - shift: f64, - /// Vertical justification: extra space added between consecutive lines. - gap: f64, -} - -/// The largest magnitude a coordinate of a frame, a text wrap shape or the path of type on a path -/// may have (points), control points included. A frame or path with a larger coordinate, or a -/// coordinate that isn't finite, holds no text, and a text wrap shape with one is ignored. -const MAX_COORD: f64 = 1e9; - -/// Are the coordinates of `path`, control points included, finite and within [`MAX_COORD`]? -fn in_range(path: &BezPath) -> bool { - let near = |p: &Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD; - path.elements().iter().all(|el| match el { - PathEl::MoveTo(p) | PathEl::LineTo(p) => near(p), - PathEl::QuadTo(a, p) => near(a) && near(p), - PathEl::CurveTo(a, b, p) => near(a) && near(b) && near(p), - PathEl::ClosePath => true, - }) -} - -/// Flattened closed polygons of a path; `None` when it isn't [`in_range`]. -fn polygons(path: &BezPath) -> Option>> { - if !in_range(path) { - return None; - } - let mut polys: Vec> = Vec::new(); - kurbo::flatten(path, 0.1, |el| match el { - PathEl::MoveTo(p) => polys.push(vec![p]), - PathEl::LineTo(p) => { - if let Some(v) = polys.last_mut() { - v.push(p); - } - } - _ => {} - }); - polys.retain(|p| p.len() >= 3); - Some(polys) -} - -/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`. -fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { - let mut xs = Vec::new(); - for poly in polys { - for i in 0..poly.len() { - let a = poly[i]; - let b = poly[(i + 1) % poly.len()]; - if (a.y <= y) != (b.y <= y) { - xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); - } - } - } - xs.sort_by(f64::total_cmp); - xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect() -} - -/// Union of sorted-or-not intervals. -fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { - v.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); - for (a, b) in v { - match out.last_mut() { - Some(l) if a <= l.1 => l.1 = l.1.max(b), - _ => out.push((a, b)), - } - } - out -} - -/// `a` minus `b` (both unions of disjoint intervals). -fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { - let mut out = vec![]; - for &(mut s, e) in a { - for &(c, d) in b { - if d <= s || c >= e { - continue; - } - if c > s { - out.push((s, c)); - } - s = s.max(d); - } - if e > s { - out.push((s, e)); - } - } - out -} - -/// `a` ∩ `b`. -fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { - let mut out = vec![]; - for &(s, e) in a { - for &(c, d) in b { - let (x, y) = (s.max(c), e.min(d)); - if y > x { - out.push((x, y)); - } - } - } - out -} - -impl Region { - fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { - // A frame out of range has no cells, and a wrap shape out of range is left out. - let Some(polys) = polygons(path) else { return vec![] }; - // Every cell shares one copy of the frame's polygons and one copy of the text wrap shapes. - let polys: std::sync::Arc<[Vec]> = polys.into(); - let wraps: std::sync::Arc<[FlatWrap]> = wrap - .iter() - .filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert))) - .filter(|w| !w.0.is_empty()) - .collect(); - let bbox = path.bounding_box(); - let rect = polys.len() == 1 && { - let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() * 0.5; - (area - bbox.area()).abs() <= bbox.area() * 1e-6 + 1e-9 - }; - let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; - let (rows, cols) = (opts.rows.clamp(1, max), opts.columns.clamp(1, max)); - let gutter = opts.gutter.max(0.0); - let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0); - let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0); - let mut out = Vec::with_capacity(rows * cols); - // Text flows down each column, then across (Illustrator's default "by columns"). - for c in 0..cols { - for r in 0..rows { - let x0 = bbox.x0 + c as f64 * (cw + gutter); - let y0 = bbox.y0 + r as f64 * (rh + gutter); - let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; - out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 }); - } - } - out - } - - fn top(&self) -> f64 { - self.cell.y0 + self.inset - } - fn bottom(&self) -> f64 { - self.cell.y1 - self.inset - } - - /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or - /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. - fn intervals(&self, y: f64) -> Vec<(f64, f64)> { - let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; - for (polys, off, invert) in self.wraps.iter() { - let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; - let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); - iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; - } - iv - } - - /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. - fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> { - self.spans(top, bottom).into_iter().max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) - } - - /// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to - /// right (text wraps on both sides of an object). - fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { - let clip = |(a, b): (f64, f64)| { - let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); - (b > a).then_some((a, b)) - }; - let plain = self.polys.is_empty() || self.rect; - if plain && self.wraps.is_empty() { - return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip).into_iter().collect(); - } - let fb = if plain { - self.cell - } else { - self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)) - }; - let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) }; - // Sample the band densely enough for curved frames (circles, blobs). - let samples = 5; - let mut rows: Vec> = - (0..samples).map(|k| self.intervals(clamp(top + (bottom - top) * k as f64 / (samples - 1) as f64))).collect(); - let mid = rows.swap_remove(samples / 2); - if !self.wraps.is_empty() { - // Wrap objects split lines: keep exactly what is free on every sampled row. - let free = rows.iter().fold(mid, |acc, r| intersect_intervals(&acc, r)); - return free.into_iter().filter_map(clip).collect(); - } - mid.into_iter() - .filter_map(|(mut a, mut b)| { - for o in &rows { - let best = - o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?; - a = best.0; - b = best.1; - } - clip((a, b)) - }) - .collect() - } -} - -/// The 10,000 cells of a frame in 100 rows and 100 columns share one copy of the frame's polygons -/// and one copy of the text wrap shapes. -#[cfg(test)] -#[test] -fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() { - let frame = kurbo::Circle::new((0.0, 0.0), 1000.0).to_path(0.1); - let square = PathData::from_bezpath(&Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1)); - let wrap = vectorcraft_doc::WrapShape { path: square, wrap: Default::default() }; - let cells = Region::cells(&frame, &LayoutOptions { rows: 100, columns: 100, ..LayoutOptions::default() }, &[wrap]); - assert_eq!(cells.len(), 10_000); - let first = &cells[0]; - assert!(!first.polys.is_empty() && !first.wraps.is_empty()); - assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr())); -} - -/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). -#[derive(Clone, Copy, Debug, PartialEq)] -struct Metrics { - asc: f64, - desc: f64, - lead: f64, - cap: f64, - xh: f64, - /// Height of the top of the ideographic em box above the baseline. - top: f64, -} - -impl Metrics { - fn of(g: &SGlyph) -> Self { - let em = g.face.units_per_em() * g.sy; - Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh, top: (g.face.ideographic_centre() + 0.5) * em } - } - 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), - })) - } - /// Distance from the frame top to the first baseline. - fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 { - let v = match fb { - FirstBaseline::Ascent => self.asc, - FirstBaseline::CapHeight => self.cap, - FirstBaseline::XHeight => self.xh, - FirstBaseline::Leading => self.lead, - FirstBaseline::Fixed => 0.0, - }; - v.max(min) - } -} - -/// Where the next line goes: the region being filled and the previous baseline in it. -#[derive(Clone)] -struct Pen<'r> { - regions: Option<&'r [Region]>, - ri: usize, - prev: Option, - pending: f64, - fb: FirstBaseline, - fb_min: f64, - /// Further spans at the current baseline (text wrapping on both sides of an object). - queued: Vec<(f64, f64, f64)>, - /// Leading measured from em box top to em box top: where the next line's em box top goes. - model: LeadingModel, - next_top: Option, - /// The narrowest word no line from here down is wide enough for (see [`Pen::place`]). - no_room_for: f64, -} - -/// The largest number of steps layout takes to move a line down a frame cell to find room for it: -/// in a cell taller than this many leads, a step is the cell's height divided by this. -const MAX_STEPS: usize = 100_000; - -impl Pen<'_> { - /// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts - /// with a word `word` wide; `None` = the frame is full (overflow). - /// In a frame that isn't a rectangle, lines too short for the word are left empty and it moves - /// down to the first one wide enough. Only when no line is does it go on the first line with - /// room for any text, and get broken there. - /// The `bool` is true for a further span at the previous line's baseline. - fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64, word: f64) -> Option<(f64, f64, f64, bool)> { - if word > 0.0 && word < self.no_room_for { - let mut pen = self.clone(); - if let Some(placed) = pen.place_at_least(est, ind_l, ind_r, word) { - *self = pen; - return Some(placed); - } - // Lines further down are no wider for this word: don't search for it, or a wider one, again. - self.no_room_for = word; - } - self.place_at_least(est, ind_l, ind_r, 0.0) - } - - /// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`. - fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> { - let Some(regions) = self.regions else { - let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est)); - return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); - }; - if !self.queued.is_empty() { - let (b, x0, x1) = self.queued.remove(0); - return Some((b, x0, x1, true)); - } - loop { - let r = regions.get(self.ri)?; - let mut baseline = match self.prev { - None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min), - None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min), - Some(b) => self.next_baseline(b, est) + r.gap, - }; - // The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's - // height when that is more, in at most `MAX_STEPS` steps. - let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64); - for _ in 0..=MAX_STEPS { - if baseline + est.desc > r.bottom() + 0.01 { - break; - } - // A rectangle's lines are all as wide: none below has more room for the word - // (until wrap shapes can narrow the first baseline: they stay bounded). - let constant = (r.rect || r.polys.is_empty()) && r.wraps.is_empty(); - let min = if constant { 0.0 } else { min }; - let fits = |&(a, b): &(f64, f64)| { - let room = b - a - ind_l - ind_r; - room > est.asc.max(1.0) && room + EPS >= min - }; - if r.wraps.is_empty() { - match r.span(baseline - est.asc, baseline + est.desc) { - Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)), - // Too narrow here is too narrow all the way down the cell: next cell. - Some(_) if constant => break, - _ => baseline += step, - } - } else { - // Wrap objects can split a line: fill every span, left to right. - let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect(); - if spans.is_empty() { - baseline += step; - continue; - } - let (a, b) = spans.remove(0); - self.queued = spans.into_iter().map(|(a, b)| (baseline, a, b)).collect(); - return Some((baseline, a, b, false)); - } - } - // Next row/column. - self.ri += 1; - self.prev = None; - self.pending = 0.0; - self.queued.clear(); - } - } - fn bottom(&self) -> f64 { - self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom()) - } - /// The baseline of the line after the one at `prev`, for a line of metrics `est`. - fn next_baseline(&self, prev: f64, est: Metrics) -> f64 { - match (self.model, self.next_top) { - (LeadingModel::EmBoxTop, Some(top)) => top + est.top + self.pending, - _ => prev + est.lead + self.pending, - } - } - /// A line was set at `baseline` with metrics `m`: where the next one goes from. - fn settled(&mut self, baseline: f64, m: Metrics) { - self.prev = Some(baseline); - self.next_top = Some(baseline - m.top + m.lead); - } -} - -/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then -/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal -/// stops align the text that follows (`rest`, up to the next tab) on the stop. -fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { - use vectorcraft_doc::TabAlign; - let rel = x - origin; - let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); - let width: f64 = seg.iter().map(|g| g.adv).sum(); - let target = |pos: f64, align: TabAlign, on: char| match align { - TabAlign::Left => pos, - TabAlign::Right => pos - width, - TabAlign::Center => pos - width / 2.0, - TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::(), - }; - let explicit = tabs.iter().find_map(|t| { - let at = target(t.position, t.align, t.align_on); - (at > rel + 1e-6).then_some((at, t.leader.as_str())) - }); - let (at, leader) = explicit.unwrap_or_else(|| { - let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); - let base = rel.max(last); - (((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "") - }); - ((at - rel).max(0.0), leader) -} - -/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the -/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their -/// glyph. The tab after a list marker (length 0) keeps its own width. -fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { - let mut x = x_start; - for (j, g) in sg.iter().enumerate().take(to).skip(from) { - x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; - } - x - x_start -} - -/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make -/// millions of outlines. -const MAX_LEADER_COPIES: f64 = 4096.0; - -/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated -/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width -/// from `origin` (where tab stops are measured from), so the leaders of lines above one another -/// line up; only whole copies are drawn. Characters the font lacks draw nothing. -fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath { - let mut out = BezPath::new(); - let glyphs: Vec<(u32, f64)> = leader - .chars() - .filter(|c| !c.is_control()) - .map(|c| { - let gid = g.face.glyph_for(c); - (gid, g.face.advance(gid) * g.sx) - }) - .collect(); - let width: f64 = glyphs.iter().map(|(_, a)| a).sum(); - if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) { - return out; - } - let first = ((from - origin) / width - EPS).ceil(); - let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize; - let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); - for k in 0..copies { - let mut pen = origin + (first + k as f64) * width; - for &(gid, adv) in &glyphs { - if gid != 0 { - let m = Affine::translate((pen, y)) * local; - out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el)); - } - pen += adv; - } - } - out -} - -/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces -/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. -/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. -fn word_width(g: &[SGlyph], i: usize) -> f64 { - /// Glyphs measured at most: a word wrapped over many lines is measured again from each line's - /// start, and one this long is broken inside whatever cell it meets. - const MAX_GLYPHS: usize = 1024; - let rest = g.get(i..).unwrap_or_default(); - let marker = rest.iter().take_while(|gl| gl.len == 0).count(); - let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); - for gl in rest.iter().skip(marker).take(MAX_GLYPHS) { - if gl.is_space() { - break; - } - if gl.is_soft_hyphen() { - return w + hyphen_glyph(gl).adv; - } - w += gl.adv; - if gl.break_after() { - break; - } - } - w -} - -/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. -/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. -/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when -/// the line may hyphenate a word; a line whose text before the overflowing word already ends -/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop -/// (`tabs`), with the line's content starting `x_start` from the tab origin. -#[allow(clippy::too_many_arguments)] -fn break_line( - text: &str, - g: &[SGlyph], - i: usize, - width: f64, - tabs: &[vectorcraft_doc::TabStop], - x_start: f64, - hyphenate: Option<(&HyphenationOptions, f64)>, - burasagari: Burasagari, - kinsoku: Kinsoku, -) -> (usize, bool) { - if !width.is_finite() { - return (g.len(), false); - } - let mut x = 0.0; - let mut last_break: Option<(usize, bool)> = None; - // A list marker (length 0) is a cluster of its own; the line holds it and at least the first - // cluster after it. - let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); - let mut j = i; - while j < g.len() { - let gl = &g[j]; - let mut cluster_end = j + 1; - while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) { - cluster_end += 1; - } - // No Break: the marked characters stay on one line, including across a space inside the run. - if gl.no_break { - while let Some(n) = g.get(cluster_end) { - if !n.no_break { - break; - } - let b = n.byte; - cluster_end += 1; - while g.get(cluster_end).is_some_and(|next| next.byte == b) { - cluster_end += 1; - } - } - } - let cluster_width: f64 = if gl.ch == '\t' { - tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 - } else { - g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() - }; - if j > first && !gl.is_space() && x + cluster_width > width + EPS { - // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { - let mut end = cluster_end; - while g.get(end).is_some_and(SGlyph::is_space) { - end += 1; - } - return (end, false); - } - break; - } - x += cluster_width; - let last = &g[cluster_end - 1]; - if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { - if last.is_soft_hyphen() { - if x + hyphen_glyph(last).adv <= width + EPS { - last_break = Some((cluster_end, true)); - } - } else { - last_break = Some((cluster_end, false)); - } - } - j = cluster_end; - } - if j >= g.len() { - return (g.len(), false); - } - // Hyphenate the word that overflows. - let word_start = last_break.map_or(i, |b| b.0); - let in_zone = |zone: f64| { - let mut te = word_start; - while te > i && g[te - 1].is_space() { - te -= 1; - } - te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS - }; - if let Some((opts, zone)) = hyphenate - && g[j].is_letter() - && !in_zone(zone) - { - let mut we = j; - while we < g.len() && !g[we].is_space() && !g[we].break_after() { - we += 1; - } - let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); - let pts: Vec = hyphen_breaks(text, g, word_start, we, opts) - .into_iter() - .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) - .collect(); - for &k in pts.iter().rev() { - if k <= j && k > i { - let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; - if w <= width + EPS { - return (k, true); - } - } - } - } - // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only - // cluster starts, so the resulting end always keeps each source cluster together. - let (end, hy) = match last_break { - Some(b) if b.0 > i => b, - _ => (j, false), - }; - (end, hy) -} - -/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width -/// (、。,.) or half width (、。); not a closing bracket, nor Latin punctuation (Latin text keeps -/// its line breaks and composer). Never an inline graphic (U+FFFC). -fn hangs(g: &SGlyph) -> bool { - g.inline.is_none() && matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none() -} - -/// Optical Margin Alignment: the share of glyph `g`'s advance that hangs outside the margin when -/// `g` starts or ends a line. Quotation marks, hyphens, full stops and commas hang whole, and -/// asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half (the amounts -/// published for Roman Hanging Punctuation). A, V, W, Y, v, w and y hang by a tenth of their -/// advance, VectorCraft's own amount for their slanted sides. Other characters, East Asian -/// punctuation (which burasagari hangs) and inline graphics don't hang. -fn optical_share(g: &SGlyph) -> f64 { - if g.inline.is_some() || g.tcy.is_some() { - return 0.0; - } - match g.ch { - '-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0, - c if is_quote(c) => 1.0, - '*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5, - 'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' => 0.1, - _ => 0.0, - } -} - -/// Roman Hanging Punctuation: the share of glyph `g`'s advance that hangs outside the margin when -/// `g` starts or ends a line: [`optical_share`]'s punctuation, and no letters. -fn roman_share(g: &SGlyph) -> f64 { - if g.ch.is_alphabetic() { 0.0 } else { optical_share(g) } -} - -/// A punctuation mark that hangs ([`optical_share`]), not a letter. -fn optical_punct(g: &SGlyph) -> bool { - !g.ch.is_alphabetic() && optical_share(g) > 0.0 -} - -/// A single or double quotation mark: straight, curly, low-9 or reversed-9. -fn is_quote(c: char) -> bool { - matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟') -} - -/// Optical Margin Alignment and Roman Hanging Punctuation: how far a line hangs outside its left -/// and right margins, from the glyph at each end of `line` (its glyphs in visual order, each with -/// the advance it is set with) and its `share` ([`optical_share`] or [`roman_share`]), and how many glyphs hang at each end → (left, right, left glyphs, -/// right glyphs). A punctuation mark followed by a quotation mark hangs together with it: `."` -/// ending a line and `'"` starting one hang both their glyphs. A line of one glyph hangs at -/// neither end. -fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) { - let n = line.len(); - if n < 2 { - return (0.0, 0.0, 0, 0); - } - let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| share(g) * adv.max(0.0)); - let punct = |k: usize| line.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && share(g) > 0.0); - let mut left = hang(0); - // The glyphs that hang at the left; the right end takes none of them. - let mut taken = usize::from(left > 0.0); - if left > 0.0 && n > 2 && punct(0) && quote(1) { - left += hang(1); - taken = 2; - } - let mut right = hang(n - 1); - let mut right_glyphs = usize::from(right > 0.0); - if right > 0.0 && n > 2 && n - 2 >= taken && punct(n - 2) && quote(n - 1) { - right += hang(n - 2); - right_glyphs = 2; - } - (left, right, taken, right_glyphs) -} - -/// Works as [`optical_hangs`] does for a right-to-left line, whose `line` is also in visual order. -/// The line is read from right to left: `."` ending it hangs both its glyphs at the left, `'"` starting it hangs both at -/// the right, and the right end takes its glyphs first. -fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) { - let reversed: Vec<(&SGlyph, f64)> = line.iter().rev().copied().collect(); - let (right, left, right_glyphs, left_glyphs) = optical_hangs(&reversed, share); - (left, right, left_glyphs, right_glyphs) -} - -/// Would a line ending at glyph index `end` split a No Break run? -fn splits_no_break(g: &[SGlyph], end: usize) -> bool { - end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) -} - -/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a -/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line -/// with the one before it). -fn kinsoku_allows(g: &[SGlyph], j: usize, kinsoku: Kinsoku) -> bool { - g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch), kinsoku)) -} - -/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph): Hard keeps -/// every character of the set off the line start and end, Soft lets 々, ー and small kana start a -/// line, None allows the break. -fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool { - match kinsoku { - Kinsoku::None => true, - Kinsoku::Hard => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)), - Kinsoku::Soft => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c) || soft_line_start(c)), - } -} - #[cfg(test)] mod wrapping_tests { use super::*; @@ -1844,1491 +457,3 @@ mod wrapping_tests { assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); } } - -#[cfg(test)] -#[test] -fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_punctuation() { - for c in ['あ', 'カ', '漢', '々', '〆', '〇', 'A'] { - assert!(is_japanese_letter(c), "{c}"); - } - // Hangul in every form: syllables, compatibility jamo (ㄱ ㅏ), half-width jamo (ᄀ). - for c in ['한', 'ㄱ', 'ㅏ', '\u{FFA1}', '。', '「', '・', '、', 'a'] { - assert!(!is_japanese_letter(c), "{c}"); - } -} - -#[cfg(test)] -#[test] -fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { - for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] { - assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); - } - for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] { - assert!(kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); - } - assert!(kinsoku_between('。', None, Kinsoku::Hard)); -} - -/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps -/// the rest of the set (half-width forms aren't in those classes); None allows any break. -#[cfg(test)] -#[test] -fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { - for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] { - assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); - assert!(kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); - } - for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] { - assert!(!kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); - assert!(kinsoku_between(a, Some(b), Kinsoku::None), "{a}{b}"); - } -} - -/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`. -fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { - // Strip leading/trailing punctuation (quotes, commas). - let (mut a, mut b) = (ws, we); - while a < b && !g[a].is_letter() { - a += 1; - } - while b > a && !g[b - 1].is_letter() { - b -= 1; - } - if b <= a || g[a..b].iter().any(|g| !g.is_letter()) { - return vec![]; - } - let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len); - let Some(word) = text.get(s..e) else { return vec![] }; - let offs: Vec = word.char_indices().map(|(o, _)| s + o).collect(); - // The first glyph of each cluster start (not inside a ligature), by its byte: one pass, not a - // search for each point. - let mut starts = std::collections::HashMap::new(); - for k in a + 1..b { - if g[k].byte != g[k - 1].byte { - starts.entry(g[k].byte).or_insert(k); - } - } - hyphen_points_with(word, opts).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect() -} - -/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the -/// paragraph's Hyphenation options) when it is given. No break comes before a space: a line that -/// may end after a dash, a Thai word or a space ends after the spaces that follow it, as in -/// [`break_line`]. -fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec { - let mut v = vec![]; - let mut ws = 0; - for (j, gl) in g.iter().enumerate() { - let end_of_word = gl.is_space() || gl.break_after(); - if end_of_word { - if let Some(opts) = hyphenate - && j > ws - { - for k in - hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) - { - v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); - } - } - let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; - if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) && !splits_no_break(g, j + 1) { - v.push(Breakpoint { end: j + 1, hyphen: hy }); - } - ws = j + 1; - } - } - if let Some(opts) = hyphenate - && g.len() > ws - { - for k in - hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) - { - v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); - } - } - v.sort_by_key(|b| b.end); - v.dedup_by_key(|b| b.end); - v -} - -/// Paragraphs longer than this (glyphs) are always broken line by line. -const MAX_COMPOSE_GLYPHS: usize = 200_000; - -/// 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, -/// and its first composed line is not its first line. -fn compose_para( - cx: &Ctx<'_>, - sg: &[SGlyph], - flex: &[Flex], - para: &ParaStyle, - pen: &Pen<'_>, - rtl: bool, - continued: bool, -) -> Option> { - let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); - let composer = cx.opts.composer.unwrap_or(para.composer); - // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese - // single-line composer does). - let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); - // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths - // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). - // The tab after a list marker always starts the first line and has a fixed width. - let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); - if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { - return None; - } - 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; - } - let total: f64 = sg.iter().map(|g| g.adv).sum(); - let mut sim = pen.clone(); - let mut widths = vec![]; - 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() { - let first = widths.is_empty(); - let ind_l = para.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 - // lines need: each fits its width, so its first word does too. - let word = if first { word_width(sg, 0) } else { 0.0 }; - let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break }; - let w = x1 - x0 - ind_l - para.right_indent; - widths.push(w); - acc += w.max(1.0); - sim.settled(b, m); - sim.pending = 0.0; - if acc > total * 1.6 + 4.0 * w.max(1.0) { - break; - } - } - let last = *widths.last()?; - 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); - let cands = candidates(cx.text, sg, para.hyphenate.then_some(¶.hyphenation), para.kinsoku); - let justify_last = para.justify == Justify::JustifyAll; - let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); - let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from, hyphen_limit }; - // First look for breaks that keep every line within its range (ragged: at most one rag zone, - // a sixth of the width, left on each line; justified: the Justification options' Minimum and - // Maximum), then accept any lines that fit, the loosest scored worst. Justified text used to - // stop at four times its stretch and fall back to the single-line composer; the documented - // default range (word spacing up to 133%) is narrow enough that most paragraphs would. - let (strict, loose) = (1.0, f64::INFINITY); - // The same rule as the line loop below: an opening bracket starting a wrapped line gives up - // the space before it. - let start_credit = |i: usize| { - let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; - sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) - }; - compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) -} - -/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers -/// counted through consecutive list paragraphs from [`TextObject::list_count`], as -/// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. -fn list_markers(t: &TextObject, paras: &[Range]) -> Vec> { - let mut count = t.list_count; - paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect() -} - -/// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal -/// right-to-left list paragraph does. -fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { - rtl && !vertical && para.list.is_some() -} - -/// The indents at the left and at the right of a line of paragraph `para` (its first line when -/// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from -/// 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. -fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { - let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; - if mirror { (para.right_indent, start) } else { (start, para.right_indent) } -} - -/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes -/// (alignment, indents, spacing, direction, mojikumi, leading model…). -fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Option<&[Region]>) { - let mut pen = Pen { - regions, - ri: 0, - prev: None, - pending: 0.0, - fb: cx.opts.first_baseline, - fb_min: cx.opts.first_baseline_min, - queued: vec![], - model: t.para_at(0).leading_model, - next_top: None, - no_room_for: f64::INFINITY, - }; - let markers = list_markers(t, paras); - 'paras: for (pi, pr) in paras.iter().enumerate() { - let para = t.para_at(pi); - let marker_text = markers.get(pi).and_then(Option::as_deref); - // The leading model is a paragraph attribute: this paragraph's lines (and the space to - // its first line) follow its own. - pen.model = para.leading_model; - let text = cx.text; - let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); - let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); - // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. - mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); - // Japanese composition: consecutive punctuation shares one half-em space. - let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; - // …and a quarter em between Japanese and Latin text. - let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; - // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); - // Threaded text: a first paragraph that continues from the frame before has its first - // line, first-line indent and list marker there. - let continued = pi == 0 && t.first_para_continues; - // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. - // Justification and Optical Margin Alignment leave them as they are. - let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; - if marker > 0 { - flex.splice(0..0, vec![Flex::default(); marker]); - if !compressed.is_empty() { - compressed.splice(0..0, vec![false; marker]); - } - if !wakan.is_empty() { - wakan.splice(0..0, vec![0.0; marker]); - } - } - // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. - let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); - if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { - for g in sg.iter_mut().filter(|g| g.inline.is_none()) { - g.leading = auto_leading(cx.style_at(g.byte)); - } - } - let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); - let lead = auto_leading(cx.style_at(pr.start)); - let st = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre()); - Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } - }; - if pi > 0 { - pen.pending += para.space_before; - } - let n = sg.len(); - let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); - // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the - // Hyphen Limit counts the lines in a row that end with a hyphen. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; - let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); - let mut hyphen_run = 0; - let mut li_para = 0; - let mut i = 0; - let mirror = mirrors_indents(para, rtl, cx.vertical); - loop { - let est = if i < n { Metrics::of(&sg[i]) } else { pm }; - let first_line = li_para == 0 && !continued; - let (ind_l, ind_r) = line_indents(para, first_line, mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(&sg, i); - // Mojikumi: an opening bracket starting a line is set flush with the line's start (the - // space before it goes), and the line has that much more room. At a paragraph's start - // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). - if !rtl - && para.mojikumi == Mojikumi::LineEndHalf - && let Some(g) = sg.get_mut(i) - && g.lead <= 0.0 - && let Some(h) = punct_half(g, Punct::Opening) - { - g.adv -= h; - g.lead += h; - } - // Place, break, then settle the baseline on the line's real metrics (moving on to the - // next row/column if it no longer fits). - let (baseline, x0, x1, end, hyph, m) = loop { - let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - }; - let width = x1 - x0 - ind_l - ind_r; - let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); - let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { - Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), - }; - let m = Metrics::max(&sg[i..end]).unwrap_or(pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { - 0.0 - } else if pen.model == LeadingModel::EmBoxTop { - // The em box top is fixed (the frame's top, or the line above's): the baseline - // hangs from it by this line's tallest em box. - if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } - } else if first_in_region { - m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) - } else { - m.lead - est.lead - }; - if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { - // Try the next row/column; overflow if there is none. - pen.ri += 1; - pen.prev = None; - pen.pending = 0.0; - pen.queued.clear(); - continue; - } - break (baseline, x0, x1, end, hyph, m); - }; - hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; - let width = ax1 - ax0; - pen.pending = 0.0; - let last_of_para = end >= n; - let mut trimmed = end; - while trimmed > i && sg[trimmed - 1].is_space() { - trimmed -= 1; - } - let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { - Some(k) => { - let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0) - } - None => 0.0, - }; - let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; - let line_start = sg.get(i).map_or(pr.start, |g| g.byte); - let line_end = sg.get(end).map_or(pr.end, |g| g.byte); - let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); - // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. - let share: Option f64> = - (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); - // The glyphs before `stop` in visual order, each with the advance it is set with. A list - // marker does not hang: it is counted with no width. - let set = |j: usize, g: &SGlyph| { - if j < marker { - 0.0 - } else if j + 1 == trimmed { - g.adv - end_trim - } else { - g.adv - } - }; - let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { - let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); - let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); - (at, glyphs) - }; - // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: - // when it doesn't fit, counting what hangs at the line's start as room; Forced: - // always). The rest of the line is aligned and justified without it, and it follows - // the line's last character. - let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); - let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) - .then(|| sg.get(trimmed - 1)) - .flatten() - .filter(|g| hangs(g)) - .map(|g| g.adv - end_trim) - .filter(|_| match para.burasagari { - Burasagari::None => false, - Burasagari::Standard => w > width + room_at_start() + EPS, - Burasagari::Forced => true, - }); - let body = if hang.is_some() { trimmed - 1 } else { trimmed }; - let w = w - hang.unwrap_or(0.0); - // The glyphs before the line's trailing spaces (and its burasagari mark), in the order - // they are drawn. - let (drawn, mut visible) = visible(body); - // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins - // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much - // wider at each end. A left-aligned line then hangs only at its left, a right-aligned - // line hangs only at its right, and centered and justified lines hang at both; Auto - // aligns a right-to-left line at its right. A right-to-left line is read from right to - // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its - // left. Justification adds no space after a punctuation mark hanging at the left, nor - // before one hanging at the right (`held`). A letter that hangs by part of its width - // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so - // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph - // attribute) hangs the same punctuation by the same amounts, and no letters; with - // Optical Margin Alignment on as well, the letters hang too. - let (opt_l, opt_r, held, hung) = if let Some(share) = share { - if let Some(h) = &hyphen { - visible.push((h, h.adv)); - } - let (l, r, left_glyphs, right_glyphs) = - if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; - let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; - let n_visible = visible.len(); - let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let held: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) - .map(|(_, &j)| j) - .collect(); - let hung: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) - .map(|(_, &j)| j) - .collect(); - (l, r, held, hung) - } else { - (0.0, 0.0, vec![], vec![]) - }; - // Justification adds nothing to the list marker's glyphs. - let mut held = held; - held.extend(i..marker.min(body)); - // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph - // scaling of a `hung` one. - let line_flex = |j: usize| { - if held.contains(&j) { - return Flex::default(); - } - let mut f = flex.get(j).copied().unwrap_or_default(); - if hung.contains(&j) { - f.glyph = (0.0, 0.0); - } - f - }; - let room = width + opt_l + opt_r; - let (align, justify) = match para.justify { - Justify::Auto => (if rtl { 2 } else { 0 }, false), - Justify::Left => (0, false), - Justify::Center => (1, false), - Justify::Right => (2, false), - Justify::JustifyLeft => (0, !last_of_para), - Justify::JustifyCenter => (1, !last_of_para), - Justify::JustifyRight => (2, !last_of_para), - Justify::JustifyAll => (0, true), - }; - let justify = justify && regions.is_some(); - let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); - let mut fit = LineFit::default(); - let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); - // A gap that justification adds between two glyphs goes into the advance of the one - // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a - // right-to-left line comes before it in the text. - let mut after: Vec> = vec![None; body.saturating_sub(i)]; - for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { - if let Some(slot) = after.get_mut(a - i) { - *slot = Some(b); - } - } - let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); - // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the - // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. - let cjk_gap = |j: usize| { - !held.contains(&j) - && drawn_after(j) - .and_then(|k| sg.get(j).zip(sg.get(k))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) - }; - let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); - // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; - let (align, justify) = match (single_word, para.justification.single_word) { - (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), - (true, SingleWordJustify::AlignLeft) => (0, false), - (true, SingleWordJustify::AlignCenter) => (1, false), - (true, SingleWordJustify::AlignRight) => (2, false), - }; - // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, - // and the word spaces and gaps take what is left. - let kashidas = - if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; - let w = w + kashidas.iter().map(|k| k.1).sum::(); - if justify && (room - w).abs() > EPS { - if cjk_gaps > 0 && room > w { - // Spread over the CJK gaps and the word spaces alike. - let per = (room - w) / (cjk_gaps + spaces) as f64; - (per_space, per_cjk) = (per, per); - } else if spaces > 0 { - // Word spacing, letter spacing and glyph scaling within their ranges, then the - // word spaces alone; composed lines may shrink word spaces (never below zero). - let line: Vec = (i..body).map(line_flex).collect(); - let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); - fit = fit_line(&line, room - w, spaces, floor); - } else if room > w { - // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let line: Vec = (i..body).map(line_flex).collect(); - fit = fit_line(&line, room - w, 0, 0.0); - let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); - let rest = room - w - used; - let gaps = (i..body) - .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) - .count(); - if gaps > 0 && rest > EPS { - per_gap = rest / gaps as f64; - } - } - } - let start_x = if justify { - ax0 - opt_l - } else if regions.is_none() { - match align { - 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, - _ => -ind_r + opt_r - w, - } - } else { - match align { - 0 => ax0 - opt_l, - 1 => ax0 - opt_l + (room - w) * 0.5, - _ => ax1 + opt_r - w, - } - }; - let li = cx.out.lines.len(); - let glyph_start = cx.out.glyphs.len(); - // Character Alignment lines smaller characters up with the line's largest em box. - let line_em = sg.get(i..end).map_or(0.0, |line| line.iter().map(glyph_em).fold(0.0, f64::max)); - let mut x_end = start_x; - // Tab stops are measured from the frame's left edge (point type: the origin). - let tab_origin = if regions.is_some() { x0 } else { 0.0 }; - // A line with tabs is measured as if it started at the left (see `set_width`), then - // aligned as a whole: its tabs stop where they were measured, however it is aligned. - let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; - // RTL's logical trailing spaces precede the visible content. Keep them outside - // the aligned content extent, just as LTR trailing spaces extend to its right. - let leading_space_width: f64 = - order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); - let mut x = start_x - leading_space_width; - // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. - let mut marker_glyphs: Option> = None; - let mut marker_tab = 0; - for (oi, &offset) in order.iter().enumerate() { - let j = i + offset; - let Some(g) = sg.get(j) else { continue }; - let mut adv = g.adv; - if j + 1 == trimmed { - adv -= end_trim; - } - // Glyph scaling a justified line adds to the glyph (1: none). - let mut scale = 1.0; - let mut leader = ""; - if g.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if g.len > 0 { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); - } - } else if j < body { - let f = line_flex(j); - if is_word_space(g) { - adv += fit.space(&f); - } else { - adv += fit.letter(&f) + fit.glyph(&f); - scale = fit.glyph_scale(&f); - } - if g.is_space() { - adv += per_space; - } else if per_cjk != 0.0 { - if cjk_gap(j) { - adv += per_cjk; - } - } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { - // Between glyphs, never inside a tate-chu-yoko block (one cell). - adv += per_gap; - } - } - // The kashida before a cluster in the text is drawn at the cluster's right, between - // it and the letter before it, and the cluster's last glyph advances over it: the - // caret, selection and hit testing take it as part of the cluster. - let kashida = kashidas - .binary_search_by_key(&g.byte, |k| k.0) - .ok() - .and_then(|at| kashidas.get(at)) - .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) - .map(|k| k.1); - adv += kashida.unwrap_or(0.0); - let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); - let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; - if j < marker { - let at = cx.out.glyphs.len(); - marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); - if j + 1 == marker { - marker_tab = at; - } - } - cx.emit(g, place, Point::new(x, y), 0.0, adv, li); - if let Some(out) = cx.out.glyphs.last_mut() { - out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; - } - if !leader.is_empty() { - // The leader belongs to the tab's glyph: the gap stays one character for the - // caret, selection and hit testing. - let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); - if let Some(tab) = cx.out.glyphs.last_mut() { - tab.outline.extend(dots); - } - } - if let Some(len) = kashida { - cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); - } - x += adv; - if j < trimmed { - x_end = x; - } - } - if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) { - cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); - } - if let Some(h) = &hyphen { - // The hyphen follows the last non-space glyph. - let hx = x_end; - cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); - x_end = hx + h.adv; - } - cx.out.lines.push(LineInfo { - rtl: rtl && !cx.vertical, - baseline, - x0: start_x, - 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, - glyph_end: cx.out.glyphs.len(), - avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, - region: if regions.is_some() { pen.ri } else { 0 }, - }); - pen.settled(baseline, m); - // Further spans of this line band share the settled baseline. - for q in &mut pen.queued { - q.0 = baseline; - } - li_para += 1; - i = end; - if i >= n { - break; - } - } - pen.pending += para.space_after; - } -} - -/// The distance along type on a path each glyph takes: its advance measured `spacing` points above -/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and -/// open up round the inside; just the advances without spacing. Glyphs start `from` along the path. -fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec { - let mut s = from; - glyphs - .iter() - .map(|g| { - let mut step = g.adv; - if spacing != 0.0 && g.adv > 1e-9 { - // How far the path turns across the glyph: positive bending away from its top. - let ((_, a), (_, b)) = (ap.at(s), ap.at(s + g.adv)); - let curvature = a.cross(b).atan2(a.dot(b)) / g.adv; - step = g.adv / (1.0 + curvature * spacing).clamp(0.25, 4.0); - } - s += step; - step - }) - .collect() -} - -/// How closely a bent glyph's curves follow the path (text space units). -const BEND_TOLERANCE: f64 = 0.01; -/// The most pieces one segment of a glyph's outline is cut into to bend it. -const BEND_PIECES: usize = 32; -/// The most elements a bent glyph outline may have; beyond that the glyph stays straight. -const BEND_MAX_ELEMENTS: usize = 8192; - -/// `outline` (text space, placed straight by `pre`, which takes the glyph's pen space there) bent -/// along `ap` as `bend` says ([`GlyphBend`]); `None` where the path doesn't turn under the glyph -/// (it stays as it is, exactly), or the bent outline would be too large or not finite. -fn bend_outline(outline: &BezPath, pre: Affine, ap: &ArcPath, bend: &GlyphBend) -> Option { - let GlyphBend { s, step, advance, rise } = *bend; - if outline.elements().is_empty() || !(advance > 1e-9 && step.is_finite() && step > 0.0 && s.is_finite() && rise.is_finite()) { - return None; - } - // How far the path turns under the glyph; none: it lies straight already. - let ((_, a), (_, b)) = (ap.at(s), ap.at(s + step)); - let turn = a.cross(b).atan2(a.dot(b)).abs(); - if !(turn.is_finite() && turn > 1e-9) { - return None; - } - let det = pre.determinant(); - if !(det.is_finite() && det.abs() > 1e-12) { - return None; - } - let to_pen = pre.inverse(); - let k = step / advance; - let map = |q: Point| { - let (p, d) = ap.at(s + q.x * k); - p + Vec2::new(-d.y, d.x) * (q.y + rise) - }; - // Pieces short enough that mapping a piece's control points stays within the tolerance: the - // error of a piece of length L under curvature c, at height y, is about L² c (1 + |y| c) / 8. - let curvature = turn / step; - let pen = to_pen * outline.clone(); - let height = pen.bounding_box().y0.abs().max(pen.bounding_box().y1.abs()) + rise.abs(); - let piece = ((8.0 * BEND_TOLERANCE) / (curvature * (1.0 + height * curvature))).sqrt(); - let pieces = |a: Point, b: Point| -> usize { - let n = ((b - a).hypot() / piece).ceil(); - if n.is_finite() { (n as usize).clamp(1, BEND_PIECES) } else { BEND_PIECES } - }; - let mut out = BezPath::new(); - let (mut start, mut cur) = (Point::ZERO, Point::ZERO); - let line = |out: &mut BezPath, from: Point, to: Point| { - let n = pieces(from, to); - for i in 1..=n { - out.line_to(map(from.lerp(to, i as f64 / n as f64))); - } - }; - for el in pen.elements() { - match *el { - PathEl::MoveTo(p) => { - out.move_to(map(p)); - (start, cur) = (p, p); - } - PathEl::LineTo(p) => { - line(&mut out, cur, p); - cur = p; - } - PathEl::QuadTo(p1, p2) => { - let c = kurbo::QuadBez::new(cur, p1, p2).raise(); - let n = pieces(cur, p1).max(pieces(p1, p2)); - for i in 0..n { - let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); - out.curve_to(map(q.p1), map(q.p2), map(q.p3)); - } - cur = p2; - } - PathEl::CurveTo(p1, p2, p3) => { - let c = kurbo::CubicBez::new(cur, p1, p2, p3); - let n = pieces(cur, p1).max(pieces(p1, p2)).max(pieces(p2, p3)); - for i in 0..n { - let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); - out.curve_to(map(q.p1), map(q.p2), map(q.p3)); - } - cur = p3; - } - PathEl::ClosePath => { - if (cur - start).hypot() > 1e-12 { - line(&mut out, cur, start); - } - out.close_path(); - cur = start; - } - } - if out.elements().len() > BEND_MAX_ELEMENTS { - return None; - } - } - let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); - out.elements() - .iter() - .all(|el| match *el { - PathEl::MoveTo(p) | PathEl::LineTo(p) => finite(p), - PathEl::QuadTo(a, b) => finite(a) && finite(b), - PathEl::CurveTo(a, b, c) => finite(a) && finite(b) && finite(c), - PathEl::ClosePath => true, - }) - .then_some(out) -} - -/// Lay type on a path out along `path` (text space), between its brackets. -fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { - cx.out.on_path = true; - // Type on a path is one line: the first paragraph's attributes align it. - let para = t.para_at(0); - // The first paragraph's direction aligns the line (Auto) and sets the caret's. - let mut rtl = None; - let mut shaped = Vec::new(); - let markers = list_markers(t, paras); - for (pi, pr) in paras.iter().enumerate() { - let text = cx.text; - let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); - rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); - let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); - // Bullets and numbers: a list paragraph's text starts after its marker here too, unless - // the paragraph continues from the frame before in its thread. - if pi > 0 || !t.first_para_continues { - let marker_text = markers.get(pi).and_then(Option::as_deref); - cx.prepend_marker(pr, t.para_at(pi), marker_text, is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs); - } - shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); - } - let rtl = rtl.unwrap_or(false); - // The text before byte `cut` in visual order, as area type reorders each line it breaks. - let visual = |cut: usize| { - let mut sg = Vec::new(); - for (pr, bidi, glyphs) in &shaped { - let n = glyphs.iter().take_while(|g| g.byte < cut).count(); - let line = glyphs.get(..n).unwrap_or_default(); - let end = glyphs.get(n).map_or(pr.end, |g| g.byte); - sg.extend(visual_order(bidi.as_ref(), pr.start, pr.start..end, line).into_iter().filter_map(|i| line.get(i).cloned())); - } - sg - }; - let text_len = cx.text.len(); - let mut sg = visual(text_len); - // A path out of range holds no text, as a path with no length holds none. - let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; - let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { - let s = style_metrics(cx.db, cx.style_at(0)); - (s.0, s.1) - }); - if ap.is_empty() { - cx.out.overflow = !sg.is_empty(); - cx.out.lines.push(LineInfo { - rtl, - baseline: 0.0, - x0: 0.0, - x1: 0.0, - ascent: m.0, - descent: m.1, - first_baseline: 0.0, - start: 0, - end: 0, - glyph_start: 0, - glyph_end: 0, - avail: (0.0, 0.0), - region: 0, - }); - return; - } - let centre = path.bounds().unwrap_or_default().center(); - let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0)); - let s_start = from * ap.len(); - let avail = (to - from) * ap.len(); - let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; - // Optical Margin Alignment and Roman Hanging Punctuation: the brackets are the margins, and the - // glyphs at the line's ends hang outside them by `hang_l` and `hang_r`, measured along the path. - // The line is aligned in a span that much longer at each end, and the glyphs that hang at the - // end (`hung_r` of them) do not make it overflow past the end bracket. On an open path a hang - // stops at the path's end. On a closed path the two hangs together take at most the stretch - // between the end and start brackets: the end where the line is placed takes its share first. - // A centered line's ends each get up to half of it, and either end takes what the other leaves. - // The hangs are measured on the steps the glyphs take where the line is placed. A right-to-left - // line is read from right to left ([`optical_hangs_rtl`]); in vertical type `sg` is in text order. - let hanging = t.optical_margin || para.roman_hanging; - let hangs = |sg: &[SGlyph], steps: &[f64]| { - if !hanging { - return (0.0, 0.0, 0); - } - let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share }; - // A list marker (the glyphs of length 0) does not hang: it is counted with no width. - let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, if g.len == 0 { 0.0 } else { step })).collect(); - let (l, r, _, hung_r) = if rtl { optical_hangs_rtl(&line, share) } else { optical_hangs(&line, share) }; - let (l, r) = if ap.is_closed() { - let gap = (ap.len() - avail).max(0.0); - match para.justify { - j if matches!(j, Justify::Right | Justify::JustifyRight) || (rtl && j == Justify::Auto) => { - let r = r.min(gap); - (l.min(gap - r), r) - } - Justify::Center | Justify::JustifyCenter => { - let l = l.min(gap - r.min(gap * 0.5)); - (l, r.min(gap - l)) - } - _ => { - let l = l.min(gap); - (l, r.min(gap - l)) - } - } - } else { - (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) - }; - (l, r, hung_r) - }; - // The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs. - let arrange = |sg: &[SGlyph]| { - let mut steps = path_steps(&ap, sg, s_start, spacing); - let w: f64 = steps.iter().sum(); - let place = |(l, r, _): (f64, f64, usize)| { - let (lo, room) = (s_start - l, avail + l + r); - match para.justify { - Justify::Auto if rtl => lo + (room - w).max(0.0), - Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), - Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), - _ => lo, - } - }; - let mut hung = hangs(sg, &steps); - let mut s0 = place(hung); - if spacing != 0.0 && s0 != s_start { - // Spaced from where the alignment puts them, the curve under them is another. That - // changes the hanging glyphs' steps, so the line is placed again from the new steps - // until it stays where it is (at most 8 times). - steps = path_steps(&ap, sg, s0, spacing); - for _ in 0..8 { - hung = hangs(sg, &steps); - let moved = place(hung); - if (moved - s0).abs() < 1e-9 { - break; - } - s0 = moved; - steps = path_steps(&ap, sg, s0, spacing); - } - } - (steps, s0, hung) - }; - // How many of the arranged glyphs fit before one runs past the end bracket (with the hang). - let fitting = |steps: &[f64], s0: f64, (hang_l, hang_r, hung_r): (f64, f64, usize)| { - let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r); - let mut x = 0.0; - steps - .iter() - .enumerate() - .take_while(|&(k, &step)| { - let end = if k + hung_r >= steps.len() { room } else { room - hang_r }; - let fit = s0 + x + step - lo <= end + 1e-6; - x += step; - fit - }) - .count() - }; - let (mut steps, mut s0, mut hung) = arrange(&sg); - // Where the text that fits ends: before the first glyph that doesn't, which threaded text - // flows on from, as from the last line of area type. - let mut fits = text_len; - // Right-to-left text that overflows keeps the start of its text, not what comes first in visual - // order: the longest start, in reading order, whose glyphs fit in visual order is laid out. - if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() { - let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect(); - starts.dedup(); - // The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it - // doesn't. - let (mut fit, mut over) = (0, starts.len()); - while over - fit > 1 { - let mid = (fit + over) / 2; - let sg = visual(starts.get(mid).copied().unwrap_or(text_len)); - let (steps, s0, hung) = arrange(&sg); - if fitting(&steps, s0, hung) == sg.len() { - fit = mid; - } else { - over = mid; - } - } - cx.out.overflow = true; - fits = starts.get(fit).copied().unwrap_or(text_len); - sg = visual(fits); - (steps, s0, hung) = arrange(&sg); - } - let fit = fitting(&steps, s0, hung); - if fit < sg.len() { - cx.out.overflow = true; - fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len); - } - // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or - // descender along the path instead of its baseline. - let rise = match t.path_align { - PathAlign::Baseline => 0.0, - PathAlign::Ascender => m.0, - PathAlign::Descender => -m.1, - PathAlign::Center => (m.0 - m.1) * 0.5, - }; - let mut x = 0.0; - for (g, &step) in sg.iter().zip(&steps).take(fit) { - let s = s0 + x; - let (p, dir) = ap.at(s + step * 0.5); - let angle = dir.y.atan2(dir.x); - let half = Affine::translate((-g.adv * 0.5, rise)); - // Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path. - let pre = match t.path_effect { - PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half, - // x axis along the tangent, y axis stays vertical. - PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half, - // x axis stays horizontal (facing the path's direction), y axis perpendicular to the path. - PathEffect::Ribbon3d => { - let sx = if dir.x < 0.0 { -1.0 } else { 1.0 }; - Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half - } - PathEffect::StairStep => Affine::translate(ap.at(s).0.to_vec2()) * Affine::translate((0.0, rise)), - // x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's - // up side, and never closer than ~17° to the baseline so glyphs stay legible). - PathEffect::Gravity => { - let n = Vec2::new(dir.y, -dir.x); - let mut up = p - centre; - up = if up.hypot() < 1e-9 { n } else { up / up.hypot() }; - if up.dot(n) < 0.0 { - up = -up; - } - if up.dot(n) < 0.3 { - let along = up - n * up.dot(n); - up = n * 0.3 + along / along.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt(); - } - Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half - } - }; - // The caret's baseline moves with the type. - let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise; - // A list marker's glyphs (the only glyphs here with no length) are in a row at the byte of - // their paragraph's start. - if g.len == 0 { - let at = cx.out.glyphs.len(); - let starts_paragraph = |b: usize| paras.iter().position(|p| p.start == b); - match cx.out.markers.last_mut() { - Some(m) if m.glyphs.end == at && cx.out.glyphs.get(m.glyphs.start).is_some_and(|h| h.byte == g.byte) => m.glyphs.end = at + 1, - _ => { - if let Some(text) = starts_paragraph(g.byte).and_then(|pi| markers.get(pi)).and_then(Option::as_ref) { - cx.out.markers.push(ListMarker { glyphs: at..at + 1, tab: at, text: text.clone() }); - } - } - } - if let Some(m) = cx.out.markers.last_mut().filter(|m| m.glyphs.end == at + 1 && g.ch == '\t') { - m.tab = at; - } - } - cx.emit(g, pre, origin, angle, g.adv, 0); - // A glyph of a script whose letters join is bent along a curved path (Rainbow), so its - // joining strokes meet its neighbours' instead of standing off the curve. - if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { - let bend = GlyphBend { s, step, advance: g.adv, rise }; - if let Some(last) = cx.out.glyphs.last_mut() - && last.image.is_none() - && last.colour.is_none() - && let Some(bent) = bend_outline(&last.outline, pre, &ap, &bend) - { - last.outline = bent; - last.bend = Some(bend); - } - } - x += step; - } - let (ps, _) = ap.at(s0); - let (pe, _) = ap.at(s0 + x); - cx.out.lines.push(LineInfo { - rtl, - baseline: ps.y, - x0: ps.x, - x1: pe.x, - ascent: m.0, - descent: m.1, - first_baseline: 0.0, - start: 0, - end: fits, - glyph_start: 0, - glyph_end: cx.out.glyphs.len(), - avail: (0.0, ap.len()), - region: 0, - }); -} - -/// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, -/// 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)) { - return None; - } - let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); - Some(BidiInfo::new(text, level)) -} - -/// [`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, -/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own -/// direction is unchanged: the first strong character is found inside embeddings too (P2). -pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option, set: &[(Range, ParaDirection)]) -> Option> { - let plain = para_bidi(text, direction); - if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) { - // A left-to-right embedding in left-to-right text takes an even level: its order is the same. - return plain; - } - // The text with the embedding controls, and where each byte of `text` is in it. - let mut marked = String::with_capacity(text.len() + set.len() * 6); - let mut at_byte: Vec = Vec::with_capacity(text.len()); - let mut copy = |marked: &mut String, from: usize, to: usize| { - if let Some(s) = text.get(from..to) { - at_byte.extend(marked.len()..marked.len() + s.len()); - marked.push_str(s); - } - }; - let mut done = 0; - for (r, d) in set { - let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len())); - if a >= b { - continue; - } - copy(&mut marked, done, a); - marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' }); - copy(&mut marked, a, b); - marked.push('\u{202C}'); - done = b; - } - copy(&mut marked, done, text.len()); - let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); - let info = BidiInfo::new(&marked, level); - let back = |i: usize| at_byte.partition_point(|&m| m < i); - let paragraphs = - info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect(); - Some(BidiInfo { - text, - original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(), - levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(), - paragraphs, - }) -} - -/// Does the paragraph run right to left ([`para_bidi`])? -pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { - bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) -} - -/// The visual order of a line's `glyphs` (indices into them), whole shaping clusters reordered -/// (UAX #9 L1–L2) for the line `line` (bytes of the text) of the paragraph starting at byte -/// `paragraph_start`; their own order when nothing is right to left (`bidi` None). -fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range, glyphs: &[SGlyph]) -> Vec { - let Some((bidi, para)) = bidi.filter(|b| b.has_rtl()).and_then(|b| Some((b, b.paragraphs.first()?))) else { - return (0..glyphs.len()).collect(); - }; - if glyphs.is_empty() { - return vec![]; - } - let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start)); - // A list marker (length 0) is a cluster of its own. - let mut clusters: Vec> = Vec::new(); - for (i, g) in glyphs.iter().enumerate() { - if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte && (p.len == 0) == (g.len == 0)) { - if let Some(c) = clusters.last_mut() { - c.end = i + 1; - } - } else { - clusters.push(i..i + 1); - } - } - // A list marker is at the paragraph's level, and at a right-to-left one its tab is drawn - // before (left of) its characters. - let cluster_levels: Vec<_> = clusters - .iter() - .filter_map(|c| glyphs.get(c.start)) - .map(|g| if g.len == 0 { g.level } else { levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level) }) - .collect(); - let reversed = |i: usize| glyphs.get(clusters.get(i).map_or(0, |c| c.start)).is_some_and(|g| g.len == 0 && g.level.is_rtl()); - unicode_bidi::BidiInfo::reorder_visual(&cluster_levels) - .into_iter() - .filter_map(|i| clusters.get(i).map(|c| (c.clone(), reversed(i)))) - .flat_map(|(c, rev)| { - let mut v: Vec = c.collect(); - if rev { - v.reverse(); - } - v - }) - .collect() -} - -/// Ruby (furigana): lays each reading out over its base text in line space, grows the line's -/// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. -/// -/// Positions are measured from the line's baseline. Vertical type uses the same geometry — ruby -/// "above" the baseline is right of the column once the lines are turned — with each reading -/// character set upright along the column (a quarter turn that the final vertical turn undoes). -/// A reading over a line break is split between the lines, by the base characters' share of it. -/// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the -/// base's width. Type on a path is left out (it has no above/below). -fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { - use std::collections::BTreeMap; - - let db = cx.db; - let text_len = cx.text.len(); - let n_lines = cx.out.lines.len(); - if n_lines == 0 || cx.out.glyphs.is_empty() { - return; - } - let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); - let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); - let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); - // One piece of base text on one line, with the part of the reading that belongs to it. - struct Piece { - line: usize, - x0: f64, - x1: f64, - run: usize, - byte: usize, - reading: String, - style: CharStyle, - position: RubyPosition, - align: RubyAlign, - base_size: f64, - overhang: bool, - } - let mut pieces: Vec = Vec::new(); - // The glyphs by their byte, to find a span's without a scan of them all for each span. - let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); - let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); - by_byte.sort_by_key(|&i| byte_of(i)); - let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); - for span in &t.ruby { - let (start, end) = (span.start.min(text_len), span.end.min(text_len)); - if start >= end || span.text.trim().is_empty() { - continue; - } - // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or - // one byte for generated glyphs). A list marker is not part of the base. - let mut by_line: BTreeMap> = BTreeMap::new(); - let hi = by_byte.partition_point(|&i| byte_of(i) < end); - let lo = by_byte.partition_point(|&i| byte_of(i).saturating_add(longest) <= start).min(hi); - let mut hits: Vec = by_byte.get(lo..hi).unwrap_or_default().to_vec(); - // In glyph order, as the scan found them. - hits.sort_unstable(); - for i in hits { - let Some(g) = cx.out.glyphs.get(i) else { continue }; - let b = g.byte.saturating_add(g.len.max(1)); - if g.byte < end && b > start && cx.out.marker_of(i).is_none() { - by_line.entry(g.line).or_default().push(i); - } - } - let total: usize = by_line.values().map(Vec::len).sum(); - if total == 0 { - continue; - } - let reading: Vec = span.text.trim().chars().collect(); - let ratio = span.safe_size_percent() / 100.0; - // Split the reading by each line's share of the base glyphs: cumulative fractions keep - // every character once and in order. - let mut taken = 0; - let mut done = 0; - let lines: Vec = by_line.keys().copied().collect(); - for line in &lines { - let count = by_line.get(line).map_or(0, Vec::len); - done += count; - let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; - let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); - taken = upto; - if part.is_empty() { - continue; - } - let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); - let mut x0 = f64::INFINITY; - let mut x1 = f64::NEG_INFINITY; - let mut base_size: f64 = 0.0; - let mut run = 0; - let mut byte = start; - for (j, &gi) in glyphs.iter().enumerate() { - let Some(g) = cx.out.glyphs.get(gi) else { continue }; - x0 = x0.min(g.origin.x); - x1 = x1.max(g.origin.x + g.advance); - base_size = base_size.max(cx.style_at(g.byte).size); - if j == 0 { - run = g.run; - byte = g.byte; - } - } - if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { - continue; - } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; - style.leading = None; - style.baseline_shift = 0.0; - style.tracking = 0.0; - style.kerning = None; - pieces.push(Piece { - line: *line, - x0, - x1, - run, - byte, - reading: part, - style, - position: span.position, - align: span.align, - base_size, - overhang: span.overhang, - }); - } - } - // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how - // far above/below the baseline its ink reaches. - let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) - for piece in &pieces { - let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; - let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); - let lay = layout(db, &mini); - let Some(first_line) = lay.lines.first() else { continue }; - let mini_base = first_line.baseline; - let mini_asc = first_line.ascent; - let mini_desc = first_line.descent; - let base_w = (piece.x1 - piece.x0).max(0.0); - // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the - // base's em box bottom (the line's descent below the baseline). - let ruby_y = if piece.position == RubyPosition::Above { - baseline - piece.base_size - mini_desc - } else { - baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc - }; - let dest = |width: f64| match piece.align { - RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, - RubyAlign::Start => piece.x0, - RubyAlign::End => piece.x1 - width, - }; - let mut glyphs: Vec = Vec::new(); - if cx.vertical { - // Each reading character in its own cell along the column, upright once the lines turn. - let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); - let mut clusters: Vec> = Vec::new(); - for g in &lay.glyphs { - match clusters.last_mut() { - Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), - _ => clusters.push(vec![g]), - } - } - let mut ruby_w = clusters.len() as f64 * cell; - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - for (ci, cluster) in clusters.iter().enumerate() { - let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); - let mut box_: Option = None; - for g in cluster { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - box_ = Some(box_.map_or(b, |a| a.union(b))); - } - let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); - let mut m = - Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in cluster { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - } else { - // The reading set along the line: the line's extent, centred or aligned to the base. - let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in &lay.glyphs { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - // How far the reading reaches above and below the base line's baseline (its ink). - let mut ascent: f64 = 0.0; - let mut descent: f64 = 0.0; - for g in &glyphs { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - ascent = ascent.max(baseline - b.y0); - descent = descent.max(b.y1 - baseline); - } - if !glyphs.is_empty() { - placed.push((piece.line, glyphs, ascent, descent)); - } - } - if placed.is_empty() { - return; - } - // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they - // need in the block direction (the first line too for area type, so the ruby stays inside the - // frame; point type's first baseline is its anchor and stays). - let mut growth = vec![0.0_f64; n_lines]; - for (line, _, ascent, descent) in &placed { - if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { - let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); - if grown > 0.0 { - let g = growth.get_mut(*line); - if let Some(slot) = g { - *slot = slot.max(grown); - } - } - } - } - // A line's ruby grows above it: the line moves on in the block direction by its own growth, - // so the line before it keeps its room (for area type the first line moves too, keeping the - // ruby inside the frame; point type's first baseline is its anchor and stays). - let area = matches!(t.kind, TextKind::Area { .. }); - let mut shift = vec![0.0_f64; n_lines]; - let mut cum = 0.0; - for i in 0..n_lines { - if area || i > 0 { - cum += growth.get(i).copied().unwrap_or(0.0); - if let Some(s) = shift.get_mut(i) { - *s = cum; - } - } - } - // Grow the lines' boxes to fit their ruby, then move them on in the block direction. - for (line, _, ascent, descent) in &placed { - if let Some(l) = cx.out.lines.get_mut(*line) { - l.ascent = l.ascent.max(*ascent); - l.descent = l.descent.max(*descent); - } - } - for (i, line) in cx.out.lines.iter_mut().enumerate() { - line.baseline += shift.get(i).copied().unwrap_or(0.0); - } - for g in &mut cx.out.glyphs { - let s = shift.get(g.line).copied().unwrap_or(0.0); - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - g.xf = Affine::translate((0.0, s)) * g.xf; - } - } - for (line, glyphs, _, _) in placed { - let s = shift.get(line).copied().unwrap_or(0.0); - for mut g in glyphs { - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - } - g.line = line; - cx.out.ruby.push(g); - } - } -} - -/// A ruby glyph from one of a reading's laid-out glyphs, moved by `m` into the base text's line -/// space. It is not a character of the text: `len` 0, the base text's `run` for fill and stroke. -fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usize) -> PositionedGlyph { - PositionedGlyph { - outline: m * g.outline.clone(), - run, - byte, - origin: m * g.origin, - advance: 0.0, - len: 0, - angle: 0.0, - line, - font_id: g.font_id, - gid: g.gid, - xf: Affine::IDENTITY, - rtl: false, - scale: 1.0, - // Ruby draws outlines only. - image: None, - colour: None, - bend: None, - } -} diff --git a/crates/text/src/layout/bidi.rs b/crates/text/src/layout/bidi.rs new file mode 100644 index 000000000..5512725a6 --- /dev/null +++ b/crates/text/src/layout/bidi.rs @@ -0,0 +1,108 @@ +use super::*; + +/// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, +/// 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)) { + return None; + } + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + Some(BidiInfo::new(text, level)) +} + +/// [`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, +/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own +/// direction is unchanged: the first strong character is found inside embeddings too (P2). +pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option, set: &[(Range, ParaDirection)]) -> Option> { + let plain = para_bidi(text, direction); + if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) { + // A left-to-right embedding in left-to-right text takes an even level: its order is the same. + return plain; + } + // The text with the embedding controls, and where each byte of `text` is in it. + let mut marked = String::with_capacity(text.len() + set.len() * 6); + let mut at_byte: Vec = Vec::with_capacity(text.len()); + let mut copy = |marked: &mut String, from: usize, to: usize| { + if let Some(s) = text.get(from..to) { + at_byte.extend(marked.len()..marked.len() + s.len()); + marked.push_str(s); + } + }; + let mut done = 0; + for (r, d) in set { + let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len())); + if a >= b { + continue; + } + copy(&mut marked, done, a); + marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' }); + copy(&mut marked, a, b); + marked.push('\u{202C}'); + done = b; + } + copy(&mut marked, done, text.len()); + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + let info = BidiInfo::new(&marked, level); + let back = |i: usize| at_byte.partition_point(|&m| m < i); + let paragraphs = + info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect(); + Some(BidiInfo { + text, + original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(), + levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(), + paragraphs, + }) +} + +/// Does the paragraph run right to left ([`para_bidi`])? +pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { + bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) +} + +/// The visual order of a line's `glyphs` (indices into them), whole shaping clusters reordered +/// (UAX #9 L1–L2) for the line `line` (bytes of the text) of the paragraph starting at byte +/// `paragraph_start`; their own order when nothing is right to left (`bidi` None). +pub(super) fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range, glyphs: &[SGlyph]) -> Vec { + let Some((bidi, para)) = bidi.filter(|b| b.has_rtl()).and_then(|b| Some((b, b.paragraphs.first()?))) else { + return (0..glyphs.len()).collect(); + }; + if glyphs.is_empty() { + return vec![]; + } + let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start)); + // A list marker (length 0) is a cluster of its own. + let mut clusters: Vec> = Vec::new(); + for (i, g) in glyphs.iter().enumerate() { + if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte && (p.len == 0) == (g.len == 0)) { + if let Some(c) = clusters.last_mut() { + c.end = i + 1; + } + } else { + clusters.push(i..i + 1); + } + } + // A list marker is at the paragraph's level, and at a right-to-left one its tab is drawn + // before (left of) its characters. + let cluster_levels: Vec<_> = clusters + .iter() + .filter_map(|c| glyphs.get(c.start)) + .map(|g| if g.len == 0 { g.level } else { levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level) }) + .collect(); + let reversed = |i: usize| glyphs.get(clusters.get(i).map_or(0, |c| c.start)).is_some_and(|g| g.len == 0 && g.level.is_rtl()); + unicode_bidi::BidiInfo::reorder_visual(&cluster_levels) + .into_iter() + .filter_map(|i| clusters.get(i).map(|c| (c.clone(), reversed(i)))) + .flat_map(|(c, rev)| { + let mut v: Vec = c.collect(); + if rev { + v.reverse(); + } + v + }) + .collect() +} diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs new file mode 100644 index 000000000..6dea3d9c6 --- /dev/null +++ b/crates/text/src/layout/breaking.rs @@ -0,0 +1,577 @@ +use super::region::Region; +use super::*; + +/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Metrics { + pub(super) asc: f64, + pub(super) desc: f64, + pub(super) lead: f64, + pub(super) cap: f64, + pub(super) xh: f64, + /// Height of the top of the ideographic em box above the baseline. + pub(super) top: f64, +} + +impl Metrics { + pub(super) fn of(g: &SGlyph) -> Self { + let em = g.face.units_per_em() * g.sy; + Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh, top: (g.face.ideographic_centre() + 0.5) * em } + } + 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), + })) + } + /// Distance from the frame top to the first baseline. + pub(super) fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 { + let v = match fb { + FirstBaseline::Ascent => self.asc, + FirstBaseline::CapHeight => self.cap, + FirstBaseline::XHeight => self.xh, + FirstBaseline::Leading => self.lead, + FirstBaseline::Fixed => 0.0, + }; + v.max(min) + } +} + +/// Where the next line goes: the region being filled and the previous baseline in it. +#[derive(Clone)] +pub(super) struct Pen<'r> { + pub(super) regions: Option<&'r [Region]>, + pub(super) ri: usize, + pub(super) prev: Option, + pub(super) pending: f64, + pub(super) fb: FirstBaseline, + pub(super) fb_min: f64, + /// Further spans at the current baseline (text wrapping on both sides of an object). + pub(super) queued: Vec<(f64, f64, f64)>, + /// Leading measured from em box top to em box top: where the next line's em box top goes. + pub(super) model: LeadingModel, + pub(super) next_top: Option, + /// The narrowest word no line from here down is wide enough for (see [`Pen::place`]). + pub(super) no_room_for: f64, +} + +/// The largest number of steps layout takes to move a line down a frame cell to find room for it: +/// in a cell taller than this many leads, a step is the cell's height divided by this. +const MAX_STEPS: usize = 100_000; + +impl Pen<'_> { + /// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts + /// with a word `word` wide; `None` = the frame is full (overflow). + /// In a frame that isn't a rectangle, lines too short for the word are left empty and it moves + /// down to the first one wide enough. Only when no line is does it go on the first line with + /// room for any text, and get broken there. + /// The `bool` is true for a further span at the previous line's baseline. + pub(super) fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64, word: f64) -> Option<(f64, f64, f64, bool)> { + if word > 0.0 && word < self.no_room_for { + let mut pen = self.clone(); + if let Some(placed) = pen.place_at_least(est, ind_l, ind_r, word) { + *self = pen; + return Some(placed); + } + // Lines further down are no wider for this word: don't search for it, or a wider one, again. + self.no_room_for = word; + } + self.place_at_least(est, ind_l, ind_r, 0.0) + } + + /// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`. + fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> { + let Some(regions) = self.regions else { + let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est)); + return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); + }; + if !self.queued.is_empty() { + let (b, x0, x1) = self.queued.remove(0); + return Some((b, x0, x1, true)); + } + loop { + let r = regions.get(self.ri)?; + let mut baseline = match self.prev { + None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min), + None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min), + Some(b) => self.next_baseline(b, est) + r.gap, + }; + // The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's + // height when that is more, in at most `MAX_STEPS` steps. + let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64); + for _ in 0..=MAX_STEPS { + if baseline + est.desc > r.bottom() + 0.01 { + break; + } + // A rectangle's lines are all as wide: none below has more room for the word + // (until wrap shapes can narrow the first baseline: they stay bounded). + let constant = (r.rect || r.polys.is_empty()) && r.wraps.is_empty(); + let min = if constant { 0.0 } else { min }; + let fits = |&(a, b): &(f64, f64)| { + let room = b - a - ind_l - ind_r; + room > est.asc.max(1.0) && room + EPS >= min + }; + if r.wraps.is_empty() { + match r.span(baseline - est.asc, baseline + est.desc) { + Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)), + // Too narrow here is too narrow all the way down the cell: next cell. + Some(_) if constant => break, + _ => baseline += step, + } + } else { + // Wrap objects can split a line: fill every span, left to right. + let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect(); + if spans.is_empty() { + baseline += step; + continue; + } + let (a, b) = spans.remove(0); + self.queued = spans.into_iter().map(|(a, b)| (baseline, a, b)).collect(); + return Some((baseline, a, b, false)); + } + } + // Next row/column. + self.ri += 1; + self.prev = None; + self.pending = 0.0; + self.queued.clear(); + } + } + pub(super) fn bottom(&self) -> f64 { + self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom()) + } + /// The baseline of the line after the one at `prev`, for a line of metrics `est`. + fn next_baseline(&self, prev: f64, est: Metrics) -> f64 { + match (self.model, self.next_top) { + (LeadingModel::EmBoxTop, Some(top)) => top + est.top + self.pending, + _ => prev + est.lead + self.pending, + } + } + /// A line was set at `baseline` with metrics `m`: where the next one goes from. + pub(super) fn settled(&mut self, baseline: f64, m: Metrics) { + self.prev = Some(baseline); + self.next_top = Some(baseline - m.top + m.lead); + } +} + +/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then +/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal +/// stops align the text that follows (`rest`, up to the next tab) on the stop. +pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { + use vectorcraft_doc::TabAlign; + let rel = x - origin; + let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); + let width: f64 = seg.iter().map(|g| g.adv).sum(); + let target = |pos: f64, align: TabAlign, on: char| match align { + TabAlign::Left => pos, + TabAlign::Right => pos - width, + TabAlign::Center => pos - width / 2.0, + TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::(), + }; + let explicit = tabs.iter().find_map(|t| { + let at = target(t.position, t.align, t.align_on); + (at > rel + 1e-6).then_some((at, t.leader.as_str())) + }); + let (at, leader) = explicit.unwrap_or_else(|| { + let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); + let base = rel.max(last); + (((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "") + }); + ((at - rel).max(0.0), leader) +} + +/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the +/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their +/// glyph. The tab after a list marker (length 0) keeps its own width. +pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { + let mut x = x_start; + for (j, g) in sg.iter().enumerate().take(to).skip(from) { + x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + } + x - x_start +} + +/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make +/// millions of outlines. +const MAX_LEADER_COPIES: f64 = 4096.0; + +/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated +/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width +/// from `origin` (where tab stops are measured from), so the leaders of lines above one another +/// line up; only whole copies are drawn. Characters the font lacks draw nothing. +pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath { + let mut out = BezPath::new(); + let glyphs: Vec<(u32, f64)> = leader + .chars() + .filter(|c| !c.is_control()) + .map(|c| { + let gid = g.face.glyph_for(c); + (gid, g.face.advance(gid) * g.sx) + }) + .collect(); + let width: f64 = glyphs.iter().map(|(_, a)| a).sum(); + if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) { + return out; + } + let first = ((from - origin) / width - EPS).ceil(); + let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize; + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + for k in 0..copies { + let mut pen = origin + (first + k as f64) * width; + for &(gid, adv) in &glyphs { + if gid != 0 { + let m = Affine::translate((pen, y)) * local; + out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el)); + } + pen += adv; + } + } + out +} + +/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces +/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. +/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. +pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { + /// Glyphs measured at most: a word wrapped over many lines is measured again from each line's + /// start, and one this long is broken inside whatever cell it meets. + const MAX_GLYPHS: usize = 1024; + let rest = g.get(i..).unwrap_or_default(); + let marker = rest.iter().take_while(|gl| gl.len == 0).count(); + let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); + for gl in rest.iter().skip(marker).take(MAX_GLYPHS) { + if gl.is_space() { + break; + } + if gl.is_soft_hyphen() { + return w + hyphen_glyph(gl).adv; + } + w += gl.adv; + if gl.break_after() { + break; + } + } + w +} + +/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. +/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. +/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when +/// the line may hyphenate a word; a line whose text before the overflowing word already ends +/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop +/// (`tabs`), with the line's content starting `x_start` from the tab origin. +#[allow(clippy::too_many_arguments)] +pub(super) fn break_line( + text: &str, + g: &[SGlyph], + i: usize, + width: f64, + tabs: &[vectorcraft_doc::TabStop], + x_start: f64, + hyphenate: Option<(&HyphenationOptions, f64)>, + burasagari: Burasagari, + kinsoku: Kinsoku, +) -> (usize, bool) { + if !width.is_finite() { + return (g.len(), false); + } + let mut x = 0.0; + let mut last_break: Option<(usize, bool)> = None; + // A list marker (length 0) is a cluster of its own; the line holds it and at least the first + // cluster after it. + let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); + let mut j = i; + while j < g.len() { + let gl = &g[j]; + let mut cluster_end = j + 1; + while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) { + cluster_end += 1; + } + // No Break: the marked characters stay on one line, including across a space inside the run. + if gl.no_break { + while let Some(n) = g.get(cluster_end) { + if !n.no_break { + break; + } + let b = n.byte; + cluster_end += 1; + while g.get(cluster_end).is_some_and(|next| next.byte == b) { + cluster_end += 1; + } + } + } + let cluster_width: f64 = if gl.ch == '\t' { + tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 + } else { + g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() + }; + if j > first && !gl.is_space() && x + cluster_width > width + EPS { + // It ends the line with the spaces after it. + if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { + let mut end = cluster_end; + while g.get(end).is_some_and(SGlyph::is_space) { + end += 1; + } + return (end, false); + } + break; + } + x += cluster_width; + let last = &g[cluster_end - 1]; + if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { + if last.is_soft_hyphen() { + if x + hyphen_glyph(last).adv <= width + EPS { + last_break = Some((cluster_end, true)); + } + } else { + last_break = Some((cluster_end, false)); + } + } + j = cluster_end; + } + if j >= g.len() { + return (g.len(), false); + } + // Hyphenate the word that overflows. + let word_start = last_break.map_or(i, |b| b.0); + let in_zone = |zone: f64| { + let mut te = word_start; + while te > i && g[te - 1].is_space() { + te -= 1; + } + te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS + }; + if let Some((opts, zone)) = hyphenate + && g[j].is_letter() + && !in_zone(zone) + { + let mut we = j; + while we < g.len() && !g[we].is_space() && !g[we].break_after() { + we += 1; + } + let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); + let pts: Vec = hyphen_breaks(text, g, word_start, we, opts) + .into_iter() + .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + .collect(); + for &k in pts.iter().rev() { + if k <= j && k > i { + let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; + if w <= width + EPS { + return (k, true); + } + } + } + } + // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only + // cluster starts, so the resulting end always keeps each source cluster together. + let (end, hy) = match last_break { + Some(b) if b.0 > i => b, + _ => (j, false), + }; + (end, hy) +} + +/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width +/// (、。,.) or half width (、。); not a closing bracket, nor Latin punctuation (Latin text keeps +/// its line breaks and composer). Never an inline graphic (U+FFFC). +pub(super) fn hangs(g: &SGlyph) -> bool { + g.inline.is_none() && matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none() +} + +/// Optical Margin Alignment: the share of glyph `g`'s advance that hangs outside the margin when +/// `g` starts or ends a line. Quotation marks, hyphens, full stops and commas hang whole, and +/// asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half (the amounts +/// published for Roman Hanging Punctuation). A, V, W, Y, v, w and y hang by a tenth of their +/// advance, VectorCraft's own amount for their slanted sides. Other characters, East Asian +/// punctuation (which burasagari hangs) and inline graphics don't hang. +pub(super) fn optical_share(g: &SGlyph) -> f64 { + if g.inline.is_some() || g.tcy.is_some() { + return 0.0; + } + match g.ch { + '-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0, + c if is_quote(c) => 1.0, + '*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5, + 'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' => 0.1, + _ => 0.0, + } +} + +/// Roman Hanging Punctuation: the share of glyph `g`'s advance that hangs outside the margin when +/// `g` starts or ends a line: [`optical_share`]'s punctuation, and no letters. +pub(super) fn roman_share(g: &SGlyph) -> f64 { + if g.ch.is_alphabetic() { 0.0 } else { optical_share(g) } +} + +/// A punctuation mark that hangs ([`optical_share`]), not a letter. +pub(super) fn optical_punct(g: &SGlyph) -> bool { + !g.ch.is_alphabetic() && optical_share(g) > 0.0 +} + +/// A single or double quotation mark: straight, curly, low-9 or reversed-9. +fn is_quote(c: char) -> bool { + matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟') +} + +/// Optical Margin Alignment and Roman Hanging Punctuation: how far a line hangs outside its left +/// and right margins, from the glyph at each end of `line` (its glyphs in visual order, each with +/// the advance it is set with) and its `share` ([`optical_share`] or [`roman_share`]), and how many glyphs hang at each end → (left, right, left glyphs, +/// right glyphs). A punctuation mark followed by a quotation mark hangs together with it: `."` +/// ending a line and `'"` starting one hang both their glyphs. A line of one glyph hangs at +/// neither end. +pub(super) fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) { + let n = line.len(); + if n < 2 { + return (0.0, 0.0, 0, 0); + } + let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| share(g) * adv.max(0.0)); + let punct = |k: usize| line.get(k).is_some_and(|&(g, _)| optical_punct(g)); + let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && share(g) > 0.0); + let mut left = hang(0); + // The glyphs that hang at the left; the right end takes none of them. + let mut taken = usize::from(left > 0.0); + if left > 0.0 && n > 2 && punct(0) && quote(1) { + left += hang(1); + taken = 2; + } + let mut right = hang(n - 1); + let mut right_glyphs = usize::from(right > 0.0); + if right > 0.0 && n > 2 && n - 2 >= taken && punct(n - 2) && quote(n - 1) { + right += hang(n - 2); + right_glyphs = 2; + } + (left, right, taken, right_glyphs) +} + +/// Works as [`optical_hangs`] does for a right-to-left line, whose `line` is also in visual order. +/// The line is read from right to left: `."` ending it hangs both its glyphs at the left, `'"` starting it hangs both at +/// the right, and the right end takes its glyphs first. +pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) { + let reversed: Vec<(&SGlyph, f64)> = line.iter().rev().copied().collect(); + let (right, left, right_glyphs, left_glyphs) = optical_hangs(&reversed, share); + (left, right, left_glyphs, right_glyphs) +} + +/// Would a line ending at glyph index `end` split a No Break run? +fn splits_no_break(g: &[SGlyph], end: usize) -> bool { + end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) +} + +/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a +/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line +/// with the one before it). +fn kinsoku_allows(g: &[SGlyph], j: usize, kinsoku: Kinsoku) -> bool { + g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch), kinsoku)) +} + +/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph): Hard keeps +/// every character of the set off the line start and end, Soft lets 々, ー and small kana start a +/// line, None allows the break. +fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool { + match kinsoku { + Kinsoku::None => true, + Kinsoku::Hard => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)), + Kinsoku::Soft => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c) || soft_line_start(c)), + } +} + +#[cfg(test)] +#[test] +fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { + for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] { + assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); + } + for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] { + assert!(kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); + } + assert!(kinsoku_between('。', None, Kinsoku::Hard)); +} + +/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps +/// the rest of the set (half-width forms aren't in those classes); None allows any break. +#[cfg(test)] +#[test] +fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { + for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] { + assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); + assert!(kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); + } + for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] { + assert!(!kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); + assert!(kinsoku_between(a, Some(b), Kinsoku::None), "{a}{b}"); + } +} + +/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`. +fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { + // Strip leading/trailing punctuation (quotes, commas). + let (mut a, mut b) = (ws, we); + while a < b && !g[a].is_letter() { + a += 1; + } + while b > a && !g[b - 1].is_letter() { + b -= 1; + } + if b <= a || g[a..b].iter().any(|g| !g.is_letter()) { + return vec![]; + } + let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len); + let Some(word) = text.get(s..e) else { return vec![] }; + let offs: Vec = word.char_indices().map(|(o, _)| s + o).collect(); + // The first glyph of each cluster start (not inside a ligature), by its byte: one pass, not a + // search for each point. + let mut starts = std::collections::HashMap::new(); + for k in a + 1..b { + if g[k].byte != g[k - 1].byte { + starts.entry(g[k].byte).or_insert(k); + } + } + hyphen_points_with(word, opts).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect() +} + +/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the +/// paragraph's Hyphenation options) when it is given. No break comes before a space: a line that +/// may end after a dash, a Thai word or a space ends after the spaces that follow it, as in +/// [`break_line`]. +pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec { + let mut v = vec![]; + let mut ws = 0; + for (j, gl) in g.iter().enumerate() { + let end_of_word = gl.is_space() || gl.break_after(); + if end_of_word { + if let Some(opts) = hyphenate + && j > ws + { + for k in + hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + { + v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); + } + } + let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; + if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) && !splits_no_break(g, j + 1) { + v.push(Breakpoint { end: j + 1, hyphen: hy }); + } + ws = j + 1; + } + } + if let Some(opts) = hyphenate + && g.len() > ws + { + for k in + hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + { + v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); + } + } + v.sort_by_key(|b| b.end); + v.dedup_by_key(|b| b.end); + v +} diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs new file mode 100644 index 000000000..2c8f74b0c --- /dev/null +++ b/crates/text/src/layout/driver.rs @@ -0,0 +1,292 @@ +use super::flow::flow; +use super::on_path::on_path; +use super::region::Region; +use super::ruby::ruby_layout; +use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds}; +use super::*; + +/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). +pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { + let a = &t.area; + let opts = LayoutOptions { + rows: a.rows, + columns: a.columns, + gutter: a.gutter, + inset: a.inset, + first_baseline: a.first_baseline, + first_baseline_min: a.first_baseline_min, + vertical_align: a.vertical_align, + fit: a.fit, + ..LayoutOptions::default() + }; + layout_with(db, t, &opts) +} + +/// Area type in a frame too short for its first line draws nothing (type that doesn't fit its frame +/// isn't drawn). For importers whose source shows that line: the frame is made taller, from its +/// top, by what the first line needs. Whether it was. +pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { + let TextKind::Area { frame } = &t.kind else { return false }; + let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; + if !layout(db, t).lines.is_empty() { + return false; + } + // The height of its first line: the line's as point type. + let point = TextObject { kind: TextKind::Point, ..t.clone() }; + let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false }; + // (A little more than it, for the rounding of the layout.) + let by = (need + 0.05) / b.height(); + if !(by.is_finite() && by > 1.0) { + return false; + } + let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); + let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); + // Something else keeps the line out (a frame narrower than a letter): as the source has it. + if layout(db, t).lines.is_empty() { + t.kind = short; + return false; + } + true +} + +/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a +/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's +/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift +/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet. +pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> { + let mut out = vec![]; + if layout.on_path || layout.vertical { + return out; + } + let glyphs = &layout.glyphs; + let mut i = 0; + while let Some(g) = glyphs.get(i) { + // The stretch: the glyphs of this run on this line, in a row. A list marker has no + // underline or strikethrough. + let marker = layout.marker_of(i).is_some(); + let end = glyphs + .iter() + .enumerate() + .skip(i) + .position(|(k, h)| h.run != g.run || h.line != g.line || layout.marker_of(k).is_some() != marker) + .map_or(glyphs.len(), |n| i + n); + let stretch = glyphs.get(i..end).unwrap_or_default(); + i = end.max(i + 1); + if marker { + continue; + } + let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + if !(style.underline || style.strikethrough) { + continue; + } + let Some(face) = db.face_by_id(g.font_id) else { continue }; + let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| { + let (s, e) = (h.origin.x, h.origin.x + h.advance); + (a.min(s).min(e), b.max(s).max(e)) + }); + for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] { + if !on { + continue; + } + // Font units run y down in the glyph's transform: the bar's top is at -offset. + let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y); + let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom)); + if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() { + out.push((g.run, bar.to_path(0.1))); + } + } + } + out +} + +/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). +const SHRINK_PASSES: usize = 11; + +/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, +/// fit, composer, OpenType features). +pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let first = layout_once(db, t, opts); + match (opts.fit.min_scale(), &t.kind) { + (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first), + _ => first, + } +} + +/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which +/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size +/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, +/// still overflowing. None (keep the full-size layout) when the text has no usable size. +fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { + // Exact size edits after object scaling may store a local size below 0.1. Fit in the + // same document points as the Character controls, independent of that representation. + let points = t.style_scale()?.points; + let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; + if !(min.is_finite() && min < 1.0) { + return None; + } + let tenths = size * 10.0; + if !tenths.is_finite() { + return None; + } + // Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow + // unless it is below the full size; `lo` is the smallest allowed. + let mut lo = (tenths * min).ceil().max(1.0); + let mut hi = tenths.floor(); + if hi < lo { + hi = lo; + } + let at = |k: f64| -> TextLayout { + let f = (k / tenths).clamp(min, 1.0); + let mut l = layout_once(db, &scaled(t, f), opts); + l.fit_scale = f; + l + }; + let mut passes = 0; + let mut best: Option = None; + // `hi` itself may fit when the size isn't a whole number of tenths. + if hi < tenths && hi > lo { + let l = at(hi); + passes += 1; + if !l.overflow { + return Some(l); + } + } + // Invariant: everything at `hi` or above overflows; `lo` fits or is the floor. + while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES { + let mid = (lo + (hi - lo) * 0.5).floor(); + let l = at(mid); + passes += 1; + if l.overflow { + hi = mid; + } else { + lo = mid; + best = Some(l); + } + } + // `best` is the layout at `lo` once `lo` has moved; at the floor it still needs laying out. + Some(best.unwrap_or_else(|| at(lo))) +} + +/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own +/// shift included (auto leading and the art's size follow the size; paragraph spacing stays). +/// Shrink Text to Fit lays `t` out as `scaled(t, f)`, where `f` is the layout's +/// [`TextLayout::fit_scale`], so the result holds the styles the text is drawn in. +pub fn scaled(t: &TextObject, f: f64) -> TextObject { + let mut s = t.clone(); + for r in &mut s.runs { + r.style.size *= f; + r.style.leading = r.style.leading.map(|l| l * f); + r.style.baseline_shift *= f; + if let Some(a) = &mut r.inline { + a.baseline_shift *= f; + } + } + s +} + +/// One layout pass, without fitting. +fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let text = t.plain_text(); + let mut runs = Vec::with_capacity(t.runs.len()); + let mut inlines = Vec::with_capacity(t.runs.len()); + let mut off = 0; + for r in &t.runs { + runs.push((off..off + r.text.len(), &r.style)); + // Only a well-formed inline run (one object replacement character) is a graphic. + inlines.push(r.inline.as_ref().filter(|_| crate::edit::is_inline_text(&r.text))); + off += r.text.len(); + } + let mut paras = Vec::new(); + let mut s = 0; + for (i, c) in text.char_indices() { + if c == '\n' { + paras.push(s..i); + s = i + 1; + } + } + paras.push(s..text.len()); + let vertical = t.vertical; + let is_on_path = matches!(&t.kind, TextKind::OnPath { .. }); + let vopts; + let opts = if vertical { + vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() }; + &vopts + } else { + opts + }; + let mut cx = Ctx { + on_path: is_on_path, + db, + text: &text, + runs, + inlines, + default: CharStyle::default(), + opts, + vertical, + system_layout: t.system_layout, + fractional_widths: t.fractional_widths, + out: TextLayout::default(), + }; + // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next + // one to the left). Point type's anchor is on the first column's centre line. Vertical path + // type stays on its path (each glyph turned across it in `emit`). + let line_xf = match &t.kind { + _ if !vertical => Affine::IDENTITY, + TextKind::OnPath { .. } => Affine::IDENTITY, + TextKind::Point => { + let first = cx.style_at(0); + let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre()); + Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN + } + _ => QUARTER_TURN, + }; + match &t.kind { + TextKind::Point => flow(&mut cx, ¶s, t, None), + TextKind::Area { frame } => { + let to_lines = line_xf.inverse(); + let wrap: Vec = if vertical { + t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect() + } else { + t.wrap.clone() + }; + let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap); + cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect(); + flow(&mut cx, ¶s, t, Some(®ions)); + if opts.vertical_align != VerticalAlign::Top { + align_vertically(&mut cx, ¶s, t, &mut regions); + } + } + TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), + } + // Ruby (furigana): placed over its base text in line space, before vertical type turns the + // lines into columns, so the same pass puts it above (right in vertical type) and widens the + // lines it sits on. Type on a path has no above/below; its ruby is left out for now. + if !is_on_path && !t.ruby.is_empty() { + ruby_layout(&mut cx, t); + } + if vertical && !is_on_path { + // Glyph origins, outlines and transforms to text space (lines stay in line space). + for g in &mut cx.out.glyphs { + g.outline.apply_affine(line_xf); + g.xf = line_xf * g.xf; + g.origin = line_xf * g.origin; + g.angle += std::f64::consts::FRAC_PI_2; + } + // Ruby glyphs are placed in line space too (upright, along the column, in the vertical + // pass); the same turn takes them to text space. + for g in &mut cx.out.ruby { + g.outline.apply_affine(line_xf); + g.xf = line_xf * g.xf; + g.origin = line_xf * g.origin; + g.angle += std::f64::consts::FRAC_PI_2; + } + for i in &mut cx.out.inlines { + i.xf = line_xf * i.xf; + i.bounds = line_xf.transform_rect_bbox(i.bounds); + } + cx.out.vertical = true; + cx.out.line_xf = line_xf; + } + finish_bounds(&mut cx.out); + cx.out +} diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs new file mode 100644 index 000000000..b923328f7 --- /dev/null +++ b/crates/text/src/layout/flow.rs @@ -0,0 +1,580 @@ +use super::bidi::{is_rtl, para_bidi_with, visual_order}; +use super::breaking::{ + Metrics, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, roman_share, + set_width, tab_advance, word_width, +}; +use super::region::Region; +use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; +use super::*; + +/// Paragraphs longer than this (glyphs) are always broken line by line. +const MAX_COMPOSE_GLYPHS: usize = 200_000; + +/// 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, +/// and its first composed line is not its first line. +fn compose_para( + cx: &Ctx<'_>, + sg: &[SGlyph], + flex: &[Flex], + para: &ParaStyle, + pen: &Pen<'_>, + rtl: bool, + continued: bool, +) -> Option> { + let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + let composer = cx.opts.composer.unwrap_or(para.composer); + // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese + // single-line composer does). + let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); + // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths + // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). + // The tab after a list marker always starts the first line and has a fixed width. + let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); + if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { + return None; + } + 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; + } + let total: f64 = sg.iter().map(|g| g.adv).sum(); + let mut sim = pen.clone(); + let mut widths = vec![]; + 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() { + let first = widths.is_empty(); + let ind_l = para.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 + // lines need: each fits its width, so its first word does too. + let word = if first { word_width(sg, 0) } else { 0.0 }; + let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break }; + let w = x1 - x0 - ind_l - para.right_indent; + widths.push(w); + acc += w.max(1.0); + sim.settled(b, m); + sim.pending = 0.0; + if acc > total * 1.6 + 4.0 * w.max(1.0) { + break; + } + } + let last = *widths.last()?; + 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); + let cands = candidates(cx.text, sg, para.hyphenate.then_some(¶.hyphenation), para.kinsoku); + let justify_last = para.justify == Justify::JustifyAll; + let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); + let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from, hyphen_limit }; + // First look for breaks that keep every line within its range (ragged: at most one rag zone, + // a sixth of the width, left on each line; justified: the Justification options' Minimum and + // Maximum), then accept any lines that fit, the loosest scored worst. Justified text used to + // stop at four times its stretch and fall back to the single-line composer; the documented + // default range (word spacing up to 133%) is narrow enough that most paragraphs would. + let (strict, loose) = (1.0, f64::INFINITY); + // The same rule as the line loop below: an opening bracket starting a wrapped line gives up + // the space before it. + let start_credit = |i: usize| { + let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; + sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) + }; + compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) +} + +/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers +/// counted through consecutive list paragraphs from [`TextObject::list_count`], as +/// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. +pub(super) fn list_markers(t: &TextObject, paras: &[Range]) -> Vec> { + let mut count = t.list_count; + paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect() +} + +/// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal +/// right-to-left list paragraph does. +fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { + rtl && !vertical && para.list.is_some() +} + +/// The indents at the left and at the right of a line of paragraph `para` (its first line when +/// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from +/// 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. +fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { + let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + if mirror { (para.right_indent, start) } else { (start, para.right_indent) } +} + +/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes +/// (alignment, indents, spacing, direction, mojikumi, leading model…). +pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Option<&[Region]>) { + let mut pen = Pen { + regions, + ri: 0, + prev: None, + pending: 0.0, + fb: cx.opts.first_baseline, + fb_min: cx.opts.first_baseline_min, + queued: vec![], + model: t.para_at(0).leading_model, + next_top: None, + no_room_for: f64::INFINITY, + }; + let markers = list_markers(t, paras); + 'paras: for (pi, pr) in paras.iter().enumerate() { + let para = t.para_at(pi); + let marker_text = markers.get(pi).and_then(Option::as_deref); + // The leading model is a paragraph attribute: this paragraph's lines (and the space to + // its first line) follow its own. + pen.model = para.leading_model; + let text = cx.text; + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); + let rtl = is_rtl(bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. + mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Japanese composition: consecutive punctuation shares one half-em space. + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + // …and a quarter em between Japanese and Latin text. + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how + // far a justified line may move each one from it. + let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // Threaded text: a first paragraph that continues from the frame before has its first + // line, first-line indent and list marker there. + let continued = pi == 0 && t.first_para_continues; + // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. + // Justification and Optical Margin Alignment leave them as they are. + let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); + } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } + // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. + let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); + if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { + for g in sg.iter_mut().filter(|g| g.inline.is_none()) { + g.leading = auto_leading(cx.style_at(g.byte)); + } + } + let pm = { + let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let lead = auto_leading(cx.style_at(pr.start)); + let st = cx.style_at(pr.start); + let (cap, xh) = cap_x_heights(cx.db, st); + let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre()); + Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } + }; + if pi > 0 { + pen.pending += para.space_before; + } + let n = sg.len(); + let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); + // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the + // Hyphen Limit counts the lines in a row that end with a hyphen. + let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); + let mut hyphen_run = 0; + let mut li_para = 0; + let mut i = 0; + let mirror = mirrors_indents(para, rtl, cx.vertical); + loop { + let est = if i < n { Metrics::of(&sg[i]) } else { pm }; + let first_line = li_para == 0 && !continued; + let (ind_l, ind_r) = line_indents(para, first_line, mirror); + // Measured before Mojikumi narrows an opening bracket, as the composer measures it. + let word = word_width(&sg, i); + // Mojikumi: an opening bracket starting a line is set flush with the line's start (the + // space before it goes), and the line has that much more room. At a paragraph's start + // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). + if !rtl + && para.mojikumi == Mojikumi::LineEndHalf + && let Some(g) = sg.get_mut(i) + && g.lead <= 0.0 + && let Some(h) = punct_half(g, Punct::Opening) + { + g.adv -= h; + g.lead += h; + } + // Place, break, then settle the baseline on the line's real metrics (moving on to the + // next row/column if it no longer fits). + let (baseline, x0, x1, end, hyph, m) = loop { + let first_in_region = pen.prev.is_none(); + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; + break 'paras; + }; + let width = x1 - x0 - ind_l - ind_r; + let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); + let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { + Some(&(e, h)) if e > i => (e, h), + _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), + _ => (n, false), + }; + let m = Metrics::max(&sg[i..end]).unwrap_or(pm); + baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + 0.0 + } else if pen.model == LeadingModel::EmBoxTop { + // The em box top is fixed (the frame's top, or the line above's): the baseline + // hangs from it by this line's tallest em box. + if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } + } else if first_in_region { + m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) + } else { + m.lead - est.lead + }; + if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { + // Try the next row/column; overflow if there is none. + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + pen.queued.clear(); + continue; + } + break (baseline, x0, x1, end, hyph, m); + }; + hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; + let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; + let width = ax1 - ax0; + pen.pending = 0.0; + let last_of_para = end >= n; + let mut trimmed = end; + while trimmed > i && sg[trimmed - 1].is_space() { + trimmed -= 1; + } + let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); + // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and + // no Japanese–Latin space is left at the end of a line. + let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { + Some(k) => { + let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; + half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0) + } + None => 0.0, + }; + let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; + let line_start = sg.get(i).map_or(pr.start, |g| g.byte); + let line_end = sg.get(end).map_or(pr.end, |g| g.byte); + let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); + // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. + let share: Option f64> = + (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); + // The glyphs before `stop` in visual order, each with the advance it is set with. A list + // marker does not hang: it is counted with no width. + let set = |j: usize, g: &SGlyph| { + if j < marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + } + }; + let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { + let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); + let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); + (at, glyphs) + }; + // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: + // when it doesn't fit, counting what hangs at the line's start as room; Forced: + // always). The rest of the line is aligned and justified without it, and it follows + // the line's last character. + let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); + let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) + .then(|| sg.get(trimmed - 1)) + .flatten() + .filter(|g| hangs(g)) + .map(|g| g.adv - end_trim) + .filter(|_| match para.burasagari { + Burasagari::None => false, + Burasagari::Standard => w > width + room_at_start() + EPS, + Burasagari::Forced => true, + }); + let body = if hang.is_some() { trimmed - 1 } else { trimmed }; + let w = w - hang.unwrap_or(0.0); + // The glyphs before the line's trailing spaces (and its burasagari mark), in the order + // they are drawn. + let (drawn, mut visible) = visible(body); + // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins + // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much + // wider at each end. A left-aligned line then hangs only at its left, a right-aligned + // line hangs only at its right, and centered and justified lines hang at both; Auto + // aligns a right-to-left line at its right. A right-to-left line is read from right to + // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its + // left. Justification adds no space after a punctuation mark hanging at the left, nor + // before one hanging at the right (`held`). A letter that hangs by part of its width + // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so + // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph + // attribute) hangs the same punctuation by the same amounts, and no letters; with + // Optical Margin Alignment on as well, the letters hang too. + let (opt_l, opt_r, held, hung) = if let Some(share) = share { + if let Some(h) = &hyphen { + visible.push((h, h.adv)); + } + let (l, r, left_glyphs, right_glyphs) = + if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; + let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; + let n_visible = visible.len(); + let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); + let held: Vec = drawn + .iter() + .enumerate() + .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) + .map(|(_, &j)| j) + .collect(); + let hung: Vec = drawn + .iter() + .enumerate() + .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) + .map(|(_, &j)| j) + .collect(); + (l, r, held, hung) + } else { + (0.0, 0.0, vec![], vec![]) + }; + // Justification adds nothing to the list marker's glyphs. + let mut held = held; + held.extend(i..marker.min(body)); + // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph + // scaling of a `hung` one. + let line_flex = |j: usize| { + if held.contains(&j) { + return Flex::default(); + } + let mut f = flex.get(j).copied().unwrap_or_default(); + if hung.contains(&j) { + f.glyph = (0.0, 0.0); + } + f + }; + let room = width + opt_l + opt_r; + let (align, justify) = match para.justify { + Justify::Auto => (if rtl { 2 } else { 0 }, false), + Justify::Left => (0, false), + Justify::Center => (1, false), + Justify::Right => (2, false), + Justify::JustifyLeft => (0, !last_of_para), + Justify::JustifyCenter => (1, !last_of_para), + Justify::JustifyRight => (2, !last_of_para), + Justify::JustifyAll => (0, true), + }; + let justify = justify && regions.is_some(); + let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); + let mut fit = LineFit::default(); + let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); + // A gap that justification adds between two glyphs goes into the advance of the one + // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a + // right-to-left line comes before it in the text. + let mut after: Vec> = vec![None; body.saturating_sub(i)]; + for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { + if let Some(slot) = after.get_mut(a - i) { + *slot = Some(b); + } + } + let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); + // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the + // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. + let cjk_gap = |j: usize| { + !held.contains(&j) + && drawn_after(j) + .and_then(|k| sg.get(j).zip(sg.get(k))) + .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) + }; + let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); + // Single Word Justification: a justified line holding one word (no word space) is + // spread between its letters (Full Justify) or aligned. + let single_word = justify && spaces == 0 && cjk_gaps == 0; + let (align, justify) = match (single_word, para.justification.single_word) { + (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), + (true, SingleWordJustify::AlignLeft) => (0, false), + (true, SingleWordJustify::AlignCenter) => (1, false), + (true, SingleWordJustify::AlignRight) => (2, false), + }; + // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, + // and the word spaces and gaps take what is left. + let kashidas = + if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; + let w = w + kashidas.iter().map(|k| k.1).sum::(); + if justify && (room - w).abs() > EPS { + if cjk_gaps > 0 && room > w { + // Spread over the CJK gaps and the word spaces alike. + let per = (room - w) / (cjk_gaps + spaces) as f64; + (per_space, per_cjk) = (per, per); + } else if spaces > 0 { + // Word spacing, letter spacing and glyph scaling within their ranges, then the + // word spaces alone; composed lines may shrink word spaces (never below zero). + let line: Vec = (i..body).map(line_flex).collect(); + let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + fit = fit_line(&line, room - w, spaces, floor); + } else if room > w { + // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. + let line: Vec = (i..body).map(line_flex).collect(); + fit = fit_line(&line, room - w, 0, 0.0); + let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); + let rest = room - w - used; + let gaps = (i..body) + .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) + .count(); + if gaps > 0 && rest > EPS { + per_gap = rest / gaps as f64; + } + } + } + let start_x = if justify { + ax0 - opt_l + } else if regions.is_none() { + match align { + 0 => ind_l - opt_l, + 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, + _ => -ind_r + opt_r - w, + } + } else { + match align { + 0 => ax0 - opt_l, + 1 => ax0 - opt_l + (room - w) * 0.5, + _ => ax1 + opt_r - w, + } + }; + let li = cx.out.lines.len(); + let glyph_start = cx.out.glyphs.len(); + // Character Alignment lines smaller characters up with the line's largest em box. + let line_em = sg.get(i..end).map_or(0.0, |line| line.iter().map(glyph_em).fold(0.0, f64::max)); + let mut x_end = start_x; + // Tab stops are measured from the frame's left edge (point type: the origin). + let tab_origin = if regions.is_some() { x0 } else { 0.0 }; + // A line with tabs is measured as if it started at the left (see `set_width`), then + // aligned as a whole: its tabs stop where they were measured, however it is aligned. + let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; + // RTL's logical trailing spaces precede the visible content. Keep them outside + // the aligned content extent, just as LTR trailing spaces extend to its right. + let leading_space_width: f64 = + order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); + let mut x = start_x - leading_space_width; + // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; + for (oi, &offset) in order.iter().enumerate() { + let j = i + offset; + let Some(g) = sg.get(j) else { continue }; + let mut adv = g.adv; + if j + 1 == trimmed { + adv -= end_trim; + } + // Glyph scaling a justified line adds to the glyph (1: none). + let mut scale = 1.0; + let mut leader = ""; + if g.ch == '\t' { + // The tab after a list marker keeps the width `prepend_marker` gave it. + if g.len > 0 { + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); + } + } else if j < body { + let f = line_flex(j); + if is_word_space(g) { + adv += fit.space(&f); + } else { + adv += fit.letter(&f) + fit.glyph(&f); + scale = fit.glyph_scale(&f); + } + if g.is_space() { + adv += per_space; + } else if per_cjk != 0.0 { + if cjk_gap(j) { + adv += per_cjk; + } + } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { + // Between glyphs, never inside a tate-chu-yoko block (one cell). + adv += per_gap; + } + } + // The kashida before a cluster in the text is drawn at the cluster's right, between + // it and the letter before it, and the cluster's last glyph advances over it: the + // caret, selection and hit testing take it as part of the cluster. + let kashida = kashidas + .binary_search_by_key(&g.byte, |k| k.0) + .ok() + .and_then(|at| kashidas.get(at)) + .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) + .map(|k| k.1); + adv += kashida.unwrap_or(0.0); + let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); + let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if j < marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); + if j + 1 == marker { + marker_tab = at; + } + } + cx.emit(g, place, Point::new(x, y), 0.0, adv, li); + if let Some(out) = cx.out.glyphs.last_mut() { + out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; + } + if !leader.is_empty() { + // The leader belongs to the tab's glyph: the gap stays one character for the + // caret, selection and hit testing. + let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); + } + } + if let Some(len) = kashida { + cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); + } + x += adv; + if j < trimmed { + x_end = x; + } + } + if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) { + cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + if let Some(h) = &hyphen { + // The hyphen follows the last non-space glyph. + let hx = x_end; + cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); + x_end = hx + h.adv; + } + cx.out.lines.push(LineInfo { + rtl: rtl && !cx.vertical, + baseline, + x0: start_x, + 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, + glyph_end: cx.out.glyphs.len(), + avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, + region: if regions.is_some() { pen.ri } else { 0 }, + }); + pen.settled(baseline, m); + // Further spans of this line band share the settled baseline. + for q in &mut pen.queued { + q.0 = baseline; + } + li_para += 1; + i = end; + if i >= n { + break; + } + } + pen.pending += para.space_after; + } +} diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs new file mode 100644 index 000000000..7bdeba66d --- /dev/null +++ b/crates/text/src/layout/on_path.rs @@ -0,0 +1,408 @@ +use super::bidi::{is_rtl, para_bidi_with, visual_order}; +use super::breaking::{Metrics, optical_hangs, optical_hangs_rtl, optical_share, roman_share}; +use super::flow::list_markers; +use super::region::in_range; +use super::*; + +/// The distance along type on a path each glyph takes: its advance measured `spacing` points above +/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and +/// open up round the inside; just the advances without spacing. Glyphs start `from` along the path. +fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec { + let mut s = from; + glyphs + .iter() + .map(|g| { + let mut step = g.adv; + if spacing != 0.0 && g.adv > 1e-9 { + // How far the path turns across the glyph: positive bending away from its top. + let ((_, a), (_, b)) = (ap.at(s), ap.at(s + g.adv)); + let curvature = a.cross(b).atan2(a.dot(b)) / g.adv; + step = g.adv / (1.0 + curvature * spacing).clamp(0.25, 4.0); + } + s += step; + step + }) + .collect() +} + +/// How closely a bent glyph's curves follow the path (text space units). +const BEND_TOLERANCE: f64 = 0.01; + +/// The most pieces one segment of a glyph's outline is cut into to bend it. +const BEND_PIECES: usize = 32; + +/// The most elements a bent glyph outline may have; beyond that the glyph stays straight. +const BEND_MAX_ELEMENTS: usize = 8192; + +/// `outline` (text space, placed straight by `pre`, which takes the glyph's pen space there) bent +/// along `ap` as `bend` says ([`GlyphBend`]); `None` where the path doesn't turn under the glyph +/// (it stays as it is, exactly), or the bent outline would be too large or not finite. +fn bend_outline(outline: &BezPath, pre: Affine, ap: &ArcPath, bend: &GlyphBend) -> Option { + let GlyphBend { s, step, advance, rise } = *bend; + if outline.elements().is_empty() || !(advance > 1e-9 && step.is_finite() && step > 0.0 && s.is_finite() && rise.is_finite()) { + return None; + } + // How far the path turns under the glyph; none: it lies straight already. + let ((_, a), (_, b)) = (ap.at(s), ap.at(s + step)); + let turn = a.cross(b).atan2(a.dot(b)).abs(); + if !(turn.is_finite() && turn > 1e-9) { + return None; + } + let det = pre.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let to_pen = pre.inverse(); + let k = step / advance; + let map = |q: Point| { + let (p, d) = ap.at(s + q.x * k); + p + Vec2::new(-d.y, d.x) * (q.y + rise) + }; + // Pieces short enough that mapping a piece's control points stays within the tolerance: the + // error of a piece of length L under curvature c, at height y, is about L² c (1 + |y| c) / 8. + let curvature = turn / step; + let pen = to_pen * outline.clone(); + let height = pen.bounding_box().y0.abs().max(pen.bounding_box().y1.abs()) + rise.abs(); + let piece = ((8.0 * BEND_TOLERANCE) / (curvature * (1.0 + height * curvature))).sqrt(); + let pieces = |a: Point, b: Point| -> usize { + let n = ((b - a).hypot() / piece).ceil(); + if n.is_finite() { (n as usize).clamp(1, BEND_PIECES) } else { BEND_PIECES } + }; + let mut out = BezPath::new(); + let (mut start, mut cur) = (Point::ZERO, Point::ZERO); + let line = |out: &mut BezPath, from: Point, to: Point| { + let n = pieces(from, to); + for i in 1..=n { + out.line_to(map(from.lerp(to, i as f64 / n as f64))); + } + }; + for el in pen.elements() { + match *el { + PathEl::MoveTo(p) => { + out.move_to(map(p)); + (start, cur) = (p, p); + } + PathEl::LineTo(p) => { + line(&mut out, cur, p); + cur = p; + } + PathEl::QuadTo(p1, p2) => { + let c = kurbo::QuadBez::new(cur, p1, p2).raise(); + let n = pieces(cur, p1).max(pieces(p1, p2)); + for i in 0..n { + let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); + out.curve_to(map(q.p1), map(q.p2), map(q.p3)); + } + cur = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + let c = kurbo::CubicBez::new(cur, p1, p2, p3); + let n = pieces(cur, p1).max(pieces(p1, p2)).max(pieces(p2, p3)); + for i in 0..n { + let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); + out.curve_to(map(q.p1), map(q.p2), map(q.p3)); + } + cur = p3; + } + PathEl::ClosePath => { + if (cur - start).hypot() > 1e-12 { + line(&mut out, cur, start); + } + out.close_path(); + cur = start; + } + } + if out.elements().len() > BEND_MAX_ELEMENTS { + return None; + } + } + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + out.elements() + .iter() + .all(|el| match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => finite(p), + PathEl::QuadTo(a, b) => finite(a) && finite(b), + PathEl::CurveTo(a, b, c) => finite(a) && finite(b) && finite(c), + PathEl::ClosePath => true, + }) + .then_some(out) +} + +/// Lay type on a path out along `path` (text space), between its brackets. +pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { + cx.out.on_path = true; + // Type on a path is one line: the first paragraph's attributes align it. + let para = t.para_at(0); + // The first paragraph's direction aligns the line (Auto) and sets the caret's. + let mut rtl = None; + let mut shaped = Vec::new(); + let markers = list_markers(t, paras); + for (pi, pr) in paras.iter().enumerate() { + let text = cx.text; + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); + rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); + let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); + // Bullets and numbers: a list paragraph's text starts after its marker here too, unless + // the paragraph continues from the frame before in its thread. + if pi > 0 || !t.first_para_continues { + let marker_text = markers.get(pi).and_then(Option::as_deref); + cx.prepend_marker(pr, t.para_at(pi), marker_text, is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs); + } + shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); + } + let rtl = rtl.unwrap_or(false); + // The text before byte `cut` in visual order, as area type reorders each line it breaks. + let visual = |cut: usize| { + let mut sg = Vec::new(); + for (pr, bidi, glyphs) in &shaped { + let n = glyphs.iter().take_while(|g| g.byte < cut).count(); + let line = glyphs.get(..n).unwrap_or_default(); + let end = glyphs.get(n).map_or(pr.end, |g| g.byte); + sg.extend(visual_order(bidi.as_ref(), pr.start, pr.start..end, line).into_iter().filter_map(|i| line.get(i).cloned())); + } + sg + }; + let text_len = cx.text.len(); + let mut sg = visual(text_len); + // A path out of range holds no text, as a path with no length holds none. + let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; + let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { + let s = style_metrics(cx.db, cx.style_at(0)); + (s.0, s.1) + }); + if ap.is_empty() { + cx.out.overflow = !sg.is_empty(); + cx.out.lines.push(LineInfo { + rtl, + baseline: 0.0, + x0: 0.0, + x1: 0.0, + ascent: m.0, + descent: m.1, + first_baseline: 0.0, + start: 0, + end: 0, + glyph_start: 0, + glyph_end: 0, + avail: (0.0, 0.0), + region: 0, + }); + return; + } + let centre = path.bounds().unwrap_or_default().center(); + let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0)); + let s_start = from * ap.len(); + let avail = (to - from) * ap.len(); + let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; + // Optical Margin Alignment and Roman Hanging Punctuation: the brackets are the margins, and the + // glyphs at the line's ends hang outside them by `hang_l` and `hang_r`, measured along the path. + // The line is aligned in a span that much longer at each end, and the glyphs that hang at the + // end (`hung_r` of them) do not make it overflow past the end bracket. On an open path a hang + // stops at the path's end. On a closed path the two hangs together take at most the stretch + // between the end and start brackets: the end where the line is placed takes its share first. + // A centered line's ends each get up to half of it, and either end takes what the other leaves. + // The hangs are measured on the steps the glyphs take where the line is placed. A right-to-left + // line is read from right to left ([`optical_hangs_rtl`]); in vertical type `sg` is in text order. + let hanging = t.optical_margin || para.roman_hanging; + let hangs = |sg: &[SGlyph], steps: &[f64]| { + if !hanging { + return (0.0, 0.0, 0); + } + let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share }; + // A list marker (the glyphs of length 0) does not hang: it is counted with no width. + let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, if g.len == 0 { 0.0 } else { step })).collect(); + let (l, r, _, hung_r) = if rtl { optical_hangs_rtl(&line, share) } else { optical_hangs(&line, share) }; + let (l, r) = if ap.is_closed() { + let gap = (ap.len() - avail).max(0.0); + match para.justify { + j if matches!(j, Justify::Right | Justify::JustifyRight) || (rtl && j == Justify::Auto) => { + let r = r.min(gap); + (l.min(gap - r), r) + } + Justify::Center | Justify::JustifyCenter => { + let l = l.min(gap - r.min(gap * 0.5)); + (l, r.min(gap - l)) + } + _ => { + let l = l.min(gap); + (l, r.min(gap - l)) + } + } + } else { + (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) + }; + (l, r, hung_r) + }; + // The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs. + let arrange = |sg: &[SGlyph]| { + let mut steps = path_steps(&ap, sg, s_start, spacing); + let w: f64 = steps.iter().sum(); + let place = |(l, r, _): (f64, f64, usize)| { + let (lo, room) = (s_start - l, avail + l + r); + match para.justify { + Justify::Auto if rtl => lo + (room - w).max(0.0), + Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), + _ => lo, + } + }; + let mut hung = hangs(sg, &steps); + let mut s0 = place(hung); + if spacing != 0.0 && s0 != s_start { + // Spaced from where the alignment puts them, the curve under them is another. That + // changes the hanging glyphs' steps, so the line is placed again from the new steps + // until it stays where it is (at most 8 times). + steps = path_steps(&ap, sg, s0, spacing); + for _ in 0..8 { + hung = hangs(sg, &steps); + let moved = place(hung); + if (moved - s0).abs() < 1e-9 { + break; + } + s0 = moved; + steps = path_steps(&ap, sg, s0, spacing); + } + } + (steps, s0, hung) + }; + // How many of the arranged glyphs fit before one runs past the end bracket (with the hang). + let fitting = |steps: &[f64], s0: f64, (hang_l, hang_r, hung_r): (f64, f64, usize)| { + let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r); + let mut x = 0.0; + steps + .iter() + .enumerate() + .take_while(|&(k, &step)| { + let end = if k + hung_r >= steps.len() { room } else { room - hang_r }; + let fit = s0 + x + step - lo <= end + 1e-6; + x += step; + fit + }) + .count() + }; + let (mut steps, mut s0, mut hung) = arrange(&sg); + // Where the text that fits ends: before the first glyph that doesn't, which threaded text + // flows on from, as from the last line of area type. + let mut fits = text_len; + // Right-to-left text that overflows keeps the start of its text, not what comes first in visual + // order: the longest start, in reading order, whose glyphs fit in visual order is laid out. + if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() { + let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect(); + starts.dedup(); + // The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it + // doesn't. + let (mut fit, mut over) = (0, starts.len()); + while over - fit > 1 { + let mid = (fit + over) / 2; + let sg = visual(starts.get(mid).copied().unwrap_or(text_len)); + let (steps, s0, hung) = arrange(&sg); + if fitting(&steps, s0, hung) == sg.len() { + fit = mid; + } else { + over = mid; + } + } + cx.out.overflow = true; + fits = starts.get(fit).copied().unwrap_or(text_len); + sg = visual(fits); + (steps, s0, hung) = arrange(&sg); + } + let fit = fitting(&steps, s0, hung); + if fit < sg.len() { + cx.out.overflow = true; + fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len); + } + // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or + // descender along the path instead of its baseline. + let rise = match t.path_align { + PathAlign::Baseline => 0.0, + PathAlign::Ascender => m.0, + PathAlign::Descender => -m.1, + PathAlign::Center => (m.0 - m.1) * 0.5, + }; + let mut x = 0.0; + for (g, &step) in sg.iter().zip(&steps).take(fit) { + let s = s0 + x; + let (p, dir) = ap.at(s + step * 0.5); + let angle = dir.y.atan2(dir.x); + let half = Affine::translate((-g.adv * 0.5, rise)); + // Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path. + let pre = match t.path_effect { + PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half, + // x axis along the tangent, y axis stays vertical. + PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half, + // x axis stays horizontal (facing the path's direction), y axis perpendicular to the path. + PathEffect::Ribbon3d => { + let sx = if dir.x < 0.0 { -1.0 } else { 1.0 }; + Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half + } + PathEffect::StairStep => Affine::translate(ap.at(s).0.to_vec2()) * Affine::translate((0.0, rise)), + // x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's + // up side, and never closer than ~17° to the baseline so glyphs stay legible). + PathEffect::Gravity => { + let n = Vec2::new(dir.y, -dir.x); + let mut up = p - centre; + up = if up.hypot() < 1e-9 { n } else { up / up.hypot() }; + if up.dot(n) < 0.0 { + up = -up; + } + if up.dot(n) < 0.3 { + let along = up - n * up.dot(n); + up = n * 0.3 + along / along.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt(); + } + Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half + } + }; + // The caret's baseline moves with the type. + let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise; + // A list marker's glyphs (the only glyphs here with no length) are in a row at the byte of + // their paragraph's start. + if g.len == 0 { + let at = cx.out.glyphs.len(); + let starts_paragraph = |b: usize| paras.iter().position(|p| p.start == b); + match cx.out.markers.last_mut() { + Some(m) if m.glyphs.end == at && cx.out.glyphs.get(m.glyphs.start).is_some_and(|h| h.byte == g.byte) => m.glyphs.end = at + 1, + _ => { + if let Some(text) = starts_paragraph(g.byte).and_then(|pi| markers.get(pi)).and_then(Option::as_ref) { + cx.out.markers.push(ListMarker { glyphs: at..at + 1, tab: at, text: text.clone() }); + } + } + } + if let Some(m) = cx.out.markers.last_mut().filter(|m| m.glyphs.end == at + 1 && g.ch == '\t') { + m.tab = at; + } + } + cx.emit(g, pre, origin, angle, g.adv, 0); + // A glyph of a script whose letters join is bent along a curved path (Rainbow), so its + // joining strokes meet its neighbours' instead of standing off the curve. + if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { + let bend = GlyphBend { s, step, advance: g.adv, rise }; + if let Some(last) = cx.out.glyphs.last_mut() + && last.image.is_none() + && last.colour.is_none() + && let Some(bent) = bend_outline(&last.outline, pre, &ap, &bend) + { + last.outline = bent; + last.bend = Some(bend); + } + } + x += step; + } + let (ps, _) = ap.at(s0); + let (pe, _) = ap.at(s0 + x); + cx.out.lines.push(LineInfo { + rtl, + baseline: ps.y, + x0: ps.x, + x1: pe.x, + ascent: m.0, + descent: m.1, + first_baseline: 0.0, + start: 0, + end: fits, + glyph_start: 0, + glyph_end: cx.out.glyphs.len(), + avail: (0.0, ap.len()), + region: 0, + }); +} diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs new file mode 100644 index 000000000..d3f953359 --- /dev/null +++ b/crates/text/src/layout/region.rs @@ -0,0 +1,233 @@ +use super::*; + +/// A flattened text wrap shape: (polygons, offset, invert). +pub(super) type FlatWrap = (Vec>, f64, bool); + +/// One cell of a flattened area-type frame (the whole frame, or one row/column of it). +pub(super) struct Region { + /// The cell (frame bounds, or a grid cell of them). + pub(super) cell: Rect, + /// Inset applied inside the frame edges. + inset: f64, + pub(super) polys: std::sync::Arc<[Vec]>, + /// The frame is its own bounding rectangle (spans need no polygon intersection). + pub(super) rect: bool, + /// Text Wrap shapes. + pub(super) wraps: std::sync::Arc<[FlatWrap]>, + /// Vertical alignment: how far below the top-aligned position the first line starts. + pub(super) shift: f64, + /// Vertical justification: extra space added between consecutive lines. + pub(super) gap: f64, +} + +/// The largest magnitude a coordinate of a frame, a text wrap shape or the path of type on a path +/// may have (points), control points included. A frame or path with a larger coordinate, or a +/// coordinate that isn't finite, holds no text, and a text wrap shape with one is ignored. +const MAX_COORD: f64 = 1e9; + +/// Are the coordinates of `path`, control points included, finite and within [`MAX_COORD`]? +pub(super) fn in_range(path: &BezPath) -> bool { + let near = |p: &Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD; + path.elements().iter().all(|el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => near(p), + PathEl::QuadTo(a, p) => near(a) && near(p), + PathEl::CurveTo(a, b, p) => near(a) && near(b) && near(p), + PathEl::ClosePath => true, + }) +} + +/// Flattened closed polygons of a path; `None` when it isn't [`in_range`]. +fn polygons(path: &BezPath) -> Option>> { + if !in_range(path) { + return None; + } + let mut polys: Vec> = Vec::new(); + kurbo::flatten(path, 0.1, |el| match el { + PathEl::MoveTo(p) => polys.push(vec![p]), + PathEl::LineTo(p) => { + if let Some(v) = polys.last_mut() { + v.push(p); + } + } + _ => {} + }); + polys.retain(|p| p.len() >= 3); + Some(polys) +} + +/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`. +fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { + let mut xs = Vec::new(); + for poly in polys { + for i in 0..poly.len() { + let a = poly[i]; + let b = poly[(i + 1) % poly.len()]; + if (a.y <= y) != (b.y <= y) { + xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); + } + } + } + xs.sort_by(f64::total_cmp); + xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect() +} + +/// Union of sorted-or-not intervals. +fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); + for (a, b) in v { + match out.last_mut() { + Some(l) if a <= l.1 => l.1 = l.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// `a` minus `b` (both unions of disjoint intervals). +fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = vec![]; + for &(mut s, e) in a { + for &(c, d) in b { + if d <= s || c >= e { + continue; + } + if c > s { + out.push((s, c)); + } + s = s.max(d); + } + if e > s { + out.push((s, e)); + } + } + out +} + +/// `a` ∩ `b`. +fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = vec![]; + for &(s, e) in a { + for &(c, d) in b { + let (x, y) = (s.max(c), e.min(d)); + if y > x { + out.push((x, y)); + } + } + } + out +} + +impl Region { + pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { + // A frame out of range has no cells, and a wrap shape out of range is left out. + let Some(polys) = polygons(path) else { return vec![] }; + // Every cell shares one copy of the frame's polygons and one copy of the text wrap shapes. + let polys: std::sync::Arc<[Vec]> = polys.into(); + let wraps: std::sync::Arc<[FlatWrap]> = wrap + .iter() + .filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert))) + .filter(|w| !w.0.is_empty()) + .collect(); + let bbox = path.bounding_box(); + let rect = polys.len() == 1 && { + let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() * 0.5; + (area - bbox.area()).abs() <= bbox.area() * 1e-6 + 1e-9 + }; + let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; + let (rows, cols) = (opts.rows.clamp(1, max), opts.columns.clamp(1, max)); + let gutter = opts.gutter.max(0.0); + let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0); + let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0); + let mut out = Vec::with_capacity(rows * cols); + // Text flows down each column, then across (Illustrator's default "by columns"). + for c in 0..cols { + for r in 0..rows { + let x0 = bbox.x0 + c as f64 * (cw + gutter); + let y0 = bbox.y0 + r as f64 * (rh + gutter); + let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; + out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 }); + } + } + out + } + + pub(super) fn top(&self) -> f64 { + self.cell.y0 + self.inset + } + pub(super) fn bottom(&self) -> f64 { + self.cell.y1 - self.inset + } + + /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or + /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. + fn intervals(&self, y: f64) -> Vec<(f64, f64)> { + let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; + for (polys, off, invert) in self.wraps.iter() { + let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; + let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); + iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; + } + iv + } + + /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. + pub(super) fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> { + self.spans(top, bottom).into_iter().max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) + } + + /// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to + /// right (text wraps on both sides of an object). + pub(super) fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let clip = |(a, b): (f64, f64)| { + let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); + (b > a).then_some((a, b)) + }; + let plain = self.polys.is_empty() || self.rect; + if plain && self.wraps.is_empty() { + return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip).into_iter().collect(); + } + let fb = if plain { + self.cell + } else { + self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)) + }; + let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) }; + // Sample the band densely enough for curved frames (circles, blobs). + let samples = 5; + let mut rows: Vec> = + (0..samples).map(|k| self.intervals(clamp(top + (bottom - top) * k as f64 / (samples - 1) as f64))).collect(); + let mid = rows.swap_remove(samples / 2); + if !self.wraps.is_empty() { + // Wrap objects split lines: keep exactly what is free on every sampled row. + let free = rows.iter().fold(mid, |acc, r| intersect_intervals(&acc, r)); + return free.into_iter().filter_map(clip).collect(); + } + mid.into_iter() + .filter_map(|(mut a, mut b)| { + for o in &rows { + let best = + o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?; + a = best.0; + b = best.1; + } + clip((a, b)) + }) + .collect() + } +} + +/// The 10,000 cells of a frame in 100 rows and 100 columns share one copy of the frame's polygons +/// and one copy of the text wrap shapes. +#[cfg(test)] +#[test] +fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() { + let frame = kurbo::Circle::new((0.0, 0.0), 1000.0).to_path(0.1); + let square = PathData::from_bezpath(&Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1)); + let wrap = vectorcraft_doc::WrapShape { path: square, wrap: Default::default() }; + let cells = Region::cells(&frame, &LayoutOptions { rows: 100, columns: 100, ..LayoutOptions::default() }, &[wrap]); + assert_eq!(cells.len(), 10_000); + let first = &cells[0]; + assert!(!first.polys.is_empty() && !first.wraps.is_empty()); + assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr())); +} diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs new file mode 100644 index 000000000..c09786bbc --- /dev/null +++ b/crates/text/src/layout/ruby.rs @@ -0,0 +1,304 @@ +use super::driver::layout; +use super::*; + +/// Ruby (furigana): lays each reading out over its base text in line space, grows the line's +/// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. +/// +/// Positions are measured from the line's baseline. Vertical type uses the same geometry — ruby +/// "above" the baseline is right of the column once the lines are turned — with each reading +/// character set upright along the column (a quarter turn that the final vertical turn undoes). +/// A reading over a line break is split between the lines, by the base characters' share of it. +/// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the +/// base's width. Type on a path is left out (it has no above/below). +pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { + use std::collections::BTreeMap; + + let db = cx.db; + let text_len = cx.text.len(); + let n_lines = cx.out.lines.len(); + if n_lines == 0 || cx.out.glyphs.is_empty() { + return; + } + let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); + let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); + let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); + // One piece of base text on one line, with the part of the reading that belongs to it. + struct Piece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, + } + let mut pieces: Vec = Vec::new(); + // The glyphs by their byte, to find a span's without a scan of them all for each span. + let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); + let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); + by_byte.sort_by_key(|&i| byte_of(i)); + let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); + for span in &t.ruby { + let (start, end) = (span.start.min(text_len), span.end.min(text_len)); + if start >= end || span.text.trim().is_empty() { + continue; + } + // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or + // one byte for generated glyphs). A list marker is not part of the base. + let mut by_line: BTreeMap> = BTreeMap::new(); + let hi = by_byte.partition_point(|&i| byte_of(i) < end); + let lo = by_byte.partition_point(|&i| byte_of(i).saturating_add(longest) <= start).min(hi); + let mut hits: Vec = by_byte.get(lo..hi).unwrap_or_default().to_vec(); + // In glyph order, as the scan found them. + hits.sort_unstable(); + for i in hits { + let Some(g) = cx.out.glyphs.get(i) else { continue }; + let b = g.byte.saturating_add(g.len.max(1)); + if g.byte < end && b > start && cx.out.marker_of(i).is_none() { + by_line.entry(g.line).or_default().push(i); + } + } + let total: usize = by_line.values().map(Vec::len).sum(); + if total == 0 { + continue; + } + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + // Split the reading by each line's share of the base glyphs: cumulative fractions keep + // every character once and in order. + let mut taken = 0; + let mut done = 0; + let lines: Vec = by_line.keys().copied().collect(); + for line in &lines { + let count = by_line.get(line).map_or(0, Vec::len); + done += count; + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (j, &gi) in glyphs.iter().enumerate() { + let Some(g) = cx.out.glyphs.get(gi) else { continue }; + x0 = x0.min(g.origin.x); + x1 = x1.max(g.origin.x + g.advance); + base_size = base_size.max(cx.style_at(g.byte).size); + if j == 0 { + run = g.run; + byte = g.byte; + } + } + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + continue; + } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + pieces.push(Piece { + line: *line, + x0, + x1, + run, + byte, + reading: part, + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }); + } + } + // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how + // far above/below the baseline its ink reaches. + let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) + for piece in &pieces { + let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let lay = layout(db, &mini); + let Some(first_line) = lay.lines.first() else { continue }; + let mini_base = first_line.baseline; + let mini_asc = first_line.ascent; + let mini_desc = first_line.descent; + let base_w = (piece.x1 - piece.x0).max(0.0); + // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the + // base's em box bottom (the line's descent below the baseline). + let ruby_y = if piece.position == RubyPosition::Above { + baseline - piece.base_size - mini_desc + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc + }; + let dest = |width: f64| match piece.align { + RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - width, + }; + let mut glyphs: Vec = Vec::new(); + if cx.vertical { + // Each reading character in its own cell along the column, upright once the lines turn. + let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); + let mut clusters: Vec> = Vec::new(); + for g in &lay.glyphs { + match clusters.last_mut() { + Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), + _ => clusters.push(vec![g]), + } + } + let mut ruby_w = clusters.len() as f64 * cell; + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + for (ci, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); + let mut box_: Option = None; + for g in cluster { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + box_ = Some(box_.map_or(b, |a| a.union(b))); + } + let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); + let mut m = + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in cluster { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + } else { + // The reading set along the line: the line's extent, centred or aligned to the base. + let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in &lay.glyphs { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + // How far the reading reaches above and below the base line's baseline (its ink). + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for g in &glyphs { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + ascent = ascent.max(baseline - b.y0); + descent = descent.max(b.y1 - baseline); + } + if !glyphs.is_empty() { + placed.push((piece.line, glyphs, ascent, descent)); + } + } + if placed.is_empty() { + return; + } + // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they + // need in the block direction (the first line too for area type, so the ruby stays inside the + // frame; point type's first baseline is its anchor and stays). + let mut growth = vec![0.0_f64; n_lines]; + for (line, _, ascent, descent) in &placed { + if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { + let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); + if grown > 0.0 { + let g = growth.get_mut(*line); + if let Some(slot) = g { + *slot = slot.max(grown); + } + } + } + } + // A line's ruby grows above it: the line moves on in the block direction by its own growth, + // so the line before it keeps its room (for area type the first line moves too, keeping the + // ruby inside the frame; point type's first baseline is its anchor and stays). + let area = matches!(t.kind, TextKind::Area { .. }); + let mut shift = vec![0.0_f64; n_lines]; + let mut cum = 0.0; + for i in 0..n_lines { + if area || i > 0 { + cum += growth.get(i).copied().unwrap_or(0.0); + if let Some(s) = shift.get_mut(i) { + *s = cum; + } + } + } + // Grow the lines' boxes to fit their ruby, then move them on in the block direction. + for (line, _, ascent, descent) in &placed { + if let Some(l) = cx.out.lines.get_mut(*line) { + l.ascent = l.ascent.max(*ascent); + l.descent = l.descent.max(*descent); + } + } + for (i, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(i).copied().unwrap_or(0.0); + } + for g in &mut cx.out.glyphs { + let s = shift.get(g.line).copied().unwrap_or(0.0); + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + g.xf = Affine::translate((0.0, s)) * g.xf; + } + } + for (line, glyphs, _, _) in placed { + let s = shift.get(line).copied().unwrap_or(0.0); + for mut g in glyphs { + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + } + g.line = line; + cx.out.ruby.push(g); + } + } +} + +/// A ruby glyph from one of a reading's laid-out glyphs, moved by `m` into the base text's line +/// space. It is not a character of the text: `len` 0, the base text's `run` for fill and stroke. +fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usize) -> PositionedGlyph { + PositionedGlyph { + outline: m * g.outline.clone(), + run, + byte, + origin: m * g.origin, + advance: 0.0, + len: 0, + angle: 0.0, + line, + font_id: g.font_id, + gid: g.gid, + xf: Affine::IDENTITY, + rtl: false, + scale: 1.0, + // Ruby draws outlines only. + image: None, + colour: None, + bend: None, + } +} diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs new file mode 100644 index 000000000..a8ffad796 --- /dev/null +++ b/crates/text/src/layout/vertical.rs @@ -0,0 +1,419 @@ +use super::flow::flow; +use super::region::Region; +use super::*; + +/// System Layout on or Fractional Widths off: each character that takes space advances by a whole +/// number of points, at least one, so small type does not collapse onto itself. A zero advance (a +/// tate-chu-yoko continuation, a zero-width mark) stays zero. +pub(super) fn whole_character_widths(glyphs: &mut [SGlyph]) { + for g in glyphs { + if g.adv.is_finite() && g.adv > 0.0 { + g.adv = g.adv.round().max(1.0); + } + } +} + +/// Tate-chu-yoko: a horizontal block one em of the column long, squeezed to the em when wider. +/// +/// Automatic: a run of two or three half-width digits (`10`月, `100`年). A single digit and a +/// longer number (`2026`) stay on their side. +/// +/// Manual (Character panel menu › Tate-chu-yoko): any characters marked `tateChuYoko`, including +/// one character, letters and numbers longer than three digits. Marked characters are taken +/// before the automatic digit rule. +/// +pub(super) fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) { + let manual: Vec = g.iter().map(|gl| style(gl).1).collect(); + let digits: Vec = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect(); + let mut i = 0; + while i < g.len() { + if manual.get(i).copied().unwrap_or(false) { + let mut end = i + 1; + while manual.get(end).copied().unwrap_or(false) { + end += 1; + } + let em = style(&g[i]).0; + place_tcy_block(&mut g[i..end], em); + i = end; + continue; + } + if !digits.get(i).copied().unwrap_or(false) { + i += 1; + continue; + } + let mut end = i + 1; + while digits.get(end).copied().unwrap_or(false) && !manual.get(end).copied().unwrap_or(false) { + end += 1; + } + if (2..=3).contains(&(end - i)) { + let em = style(&g[i]).0; + place_tcy_block(&mut g[i..end], em); + } + i = end; + } +} + +/// Lay `g` across one em of the column. The first glyph takes the em; the rest share its cell. +fn place_tcy_block(g: &mut [SGlyph], em: f64) { + let width: f64 = g.iter().map(|gl| gl.adv).sum(); + let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 }; + let mut ink = 0.0; + for (k, gl) in g.iter_mut().enumerate() { + let adv = gl.adv; + gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze }); + gl.adv = if k == 0 { em } else { 0.0 }; + ink += adv; + } +} + +/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its +/// advance an em, give or take a tenth), the space that mojikumi can take off. +pub(super) fn punct_half(g: &SGlyph, kind: Punct) -> Option { + // A glyph Proportional Metrics re-spaced has no empty half left to take off (#966). + if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional { + return None; + } + let em = g.face.units_per_em(); + ((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx) +} + +/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket, +/// comma or full stop followed by punctuation loses the space after it; an opening bracket after +/// another loses the space before it. Returns which glyphs lost the space after them (a line +/// ending in one takes nothing more off). +pub(super) fn compress_punctuation(sg: &mut [SGlyph]) -> Vec { + let mut lost_after = vec![false; sg.len()]; + for j in 1..sg.len() { + let (before, after) = sg.split_at_mut(j); + let (Some(a), Some(b)) = (before.last_mut(), after.first_mut()) else { continue }; + if punct(b.ch).is_none() { + continue; + } + if let Some(h) = punct_half(a, Punct::Closing) { + a.adv -= h; + if let Some(l) = lost_after.get_mut(j - 1) { + *l = true; + } + } else if punct(a.ch) == Some(Punct::Opening) + && let Some(h) = punct_half(b, Punct::Opening) + { + b.adv -= h; + b.lead += h; + } + } + lost_after +} + +/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the +/// Japanese characters' size), with Line-end Punctuation Half Width. +const WAKAN_AKI: f64 = 0.25; + +/// A Japanese character for the space next to Latin text: kana and kanji (and full-width +/// letters, and the iteration and abbreviation marks 々 〆 〇), not punctuation or symbols, nor +/// Hangul (Korean sets a word space instead). +fn is_japanese_letter(c: char) -> bool { + if matches!(c, '々' | '〆' | '〇') { + return true; + } + is_cjk(c) + && !matches!( + c as u32, + 0x3000..=0x303F | 0x3130..=0x318F | 0xAC00..=0xD7AF | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 | 0xFFA0..=0xFFDC + ) + && c != '・' + && punct(c).is_none() +} + +/// A Latin letter or digit (or another script's letter), set proportionally. +fn is_latin_letter(c: char) -> bool { + c.is_alphanumeric() && !is_cjk(c) +} + +/// Mojikumi (JLREQ 3.2.2): a quarter em between a Japanese character and a Latin letter or digit, +/// either way round, added after the first of the two. Returns what each glyph got after it (taken +/// off again when the line ends there). +pub(super) fn space_japanese_and_latin(sg: &mut [SGlyph]) -> Vec { + let mut added = vec![0.0; sg.len()]; + for j in 1..sg.len() { + let (before, after) = sg.split_at_mut(j); + let (Some(a), Some(b)) = (before.last_mut(), after.first()) else { continue }; + if a.tcy.is_some() || b.tcy.is_some() { + continue; + } + let japanese = if is_japanese_letter(a.ch) && is_latin_letter(b.ch) { + Some(&*a) + } else if is_latin_letter(a.ch) && is_japanese_letter(b.ch) { + Some(b) + } else { + None + }; + if let Some(g) = japanese { + let aki = WAKAN_AKI * g.face.units_per_em() * g.sx; + a.adv += aki; + if let Some(x) = added.get_mut(j - 1) { + *x = aki; + } + } + } + added +} + +/// The size of `g`'s em, in points. +pub(super) fn glyph_em(g: &SGlyph) -> f64 { + g.face.units_per_em() * g.sy +} + +/// How far up (line space) Character Alignment `a` moves glyph `g` on a line whose largest em is +/// `line_em`: the glyph's em box top, centre or bottom onto the line's (the em box running from +/// its centre less half an em to its centre plus half an em above the baseline), or its ICF's top +/// or bottom (the em box's less the face's [`crate::IcfMargins`]; across a vertical line, its +/// right and left). Nothing on the Roman baseline, or for the line's largest characters. +/// +/// An inline graphic has its run's em (whatever its scale): it counts as one of the line's +/// largest characters only when its run's size is, and moves with the text of its run (its art +/// stays centred on that text's cap height). +pub(super) fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64, vertical: bool) -> f64 { + use vectorcraft_doc::CharAlign; + let icf = || g.face.icf_margins(); + let k = match a { + CharAlign::RomanBaseline => return 0.0, + CharAlign::EmBoxTop => 0.5, + CharAlign::EmBoxCenter => 0.0, + CharAlign::EmBoxBottom => -0.5, + CharAlign::IcfTop => 0.5 - if vertical { icf().right } else { icf().top }, + CharAlign::IcfBottom => -0.5 + if vertical { icf().left } else { icf().bottom }, + }; + (g.face.ideographic_centre() + k) * (line_em - glyph_em(g)).max(0.0) +} + +/// The length an upright glyph takes down the column before tracking and justification: its +/// vertical advance (the font's vertical metrics), else its advance, at least one em. +pub(super) fn upright_cell(g: &SGlyph) -> f64 { + match g.face.vertical_glyph(g.gid) { + Some((advance, _)) => advance * g.sy, + None => (g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx), + } +} + +/// Height above the baseline (line space) of the centre of an upright glyph's `cell`, the point it +/// turns about: from the font's vertical metrics, the cell hanging from the glyph's vertical origin; +/// else the centre of the ideographic em box ([`EM_CENTER`] of the size `em`). +pub(super) fn upright_centre(g: &SGlyph, cell: f64, em: f64) -> f64 { + match g.face.vertical_glyph(g.gid) { + Some((_, origin)) => origin * g.sy - cell * 0.5, + None => EM_CENTER * em, + } +} + +/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs +/// from 0.12 em below the baseline to 0.88 em above it), for fonts without vertical metrics. +pub(super) const EM_CENTER: f64 = 0.38; + +/// A quarter turn clockwise (y down), exact: line space → text space for vertical type. +pub(super) const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + +/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical +/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on +/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on +/// their side when the font has none (Unicode's Vertical_Orientation Tr). +/// Fullwidth !/? instead keep the nominal glyph upright (Tu fallback, UAX #50 §3.1): +/// . +pub(super) fn stands_upright(g: &SGlyph) -> bool { + let c = g.ch as u32; + let upright = matches!(c, + 0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7 + | 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051 + | 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117 + | 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189 + | 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59 + | 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF + | 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7 + | 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF); + if !upright { + return false; + } + let needs_vertical_form = matches!(c, + 0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC + | 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B + | 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3); + // Without a vertical alternate the shaped glyph is the nominal one. + !needs_vertical_form || g.gid != g.face.glyph_for(g.ch) +} + +pub(super) fn finish_bounds(out: &mut TextLayout) { + let xf = out.line_xf; + let mut b: Option = None; + let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r))); + for g in &out.glyphs { + if !g.outline.elements().is_empty() { + add(g.outline.bounding_box()); + } + if let Some((image, xf)) = g.placed_image() { + add(xf.transform_rect_bbox(image.rect())); + } + if let Some(b) = g.colour.as_ref().and_then(|c| c.geometric_bounds()) { + add(g.xf.transform_rect_bbox(b)); + } + } + // Ruby: covered by its line's ascent, but its ink can overhang sideways past the line. + for g in &out.ruby { + if !g.outline.elements().is_empty() { + add(g.outline.bounding_box()); + } + } + for i in &out.inlines { + add(i.bounds); + } + if !out.on_path { + for l in &out.lines { + add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent))); + } + } + out.bounds = b.unwrap_or_default(); +} + +/// Space left in each cell after flowing top-aligned: (space above the first line's ascent, space +/// below the last line's descent, number of distinct baselines). Cells without lines are `None`. +fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec> { + regions + .iter() + .enumerate() + .map(|(ri, r)| { + let mut top = f64::INFINITY; + let mut bottom = f64::NEG_INFINITY; + let mut baselines: Vec = vec![]; + for l in out.lines.iter().filter(|l| l.region == ri) { + top = top.min(l.baseline - l.ascent); + bottom = bottom.max(l.baseline + l.descent); + if !baselines.iter().any(|b| (b - l.baseline).abs() < 1e-6) { + baselines.push(l.baseline); + } + } + if baselines.is_empty() || !top.is_finite() || !bottom.is_finite() { + return None; + } + // Never negative: a full (or overflowing) cell stays where top alignment put it. + Some(((top - r.top()).max(0.0), (r.bottom() - bottom).max(0.0), baselines.len())) + }) + .collect() +} + +/// Area Type Options "Align" other than Top: move each cell's lines down. Rectangular cells +/// without text wrap shift their lines (centre: half the space left below the last line; +/// bottom: all of it; justify: the first line stays, each further line gets an equal share of it). +/// Other frames give lines different widths at different heights, so the text flows again with +/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a +/// pass that would push text out of the frame is undone and retried with half the step. +pub(super) fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: &mut [Region]) { + let align = cx.opts.vertical_align; + let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()); + if plain { + let space = cell_space(&cx.out, regions); + // Each distinct baseline's index within its cell, for justify. + let mut seen: Vec> = vec![vec![]; regions.len()]; + let offs: Vec = cx + .out + .lines + .iter() + .map(|l| { + let Some(Some((_, below, n))) = space.get(l.region).copied() else { return 0.0 }; + match align { + VerticalAlign::Top => 0.0, + VerticalAlign::Center => below * 0.5, + VerticalAlign::Bottom => below, + VerticalAlign::Justify => { + let Some(s) = seen.get_mut(l.region) else { return 0.0 }; + let k = s.iter().position(|b| (b - l.baseline).abs() < 1e-6).unwrap_or_else(|| { + s.push(l.baseline); + s.len() - 1 + }); + if n > 1 { below * k as f64 / (n - 1) as f64 } else { 0.0 } + } + } + }) + .collect(); + for (l, d) in cx.out.lines.iter_mut().zip(&offs) { + l.baseline += d; + } + for g in &mut cx.out.glyphs { + let d = offs.get(g.line).copied().unwrap_or(0.0); + if d != 0.0 { + let m = Affine::translate((0.0, d)); + g.outline.apply_affine(m); + g.xf = m * g.xf; + g.origin.y += d; + } + } + // Inline art moves with its glyph's line. + for i in &mut cx.out.inlines { + let d = cx.out.glyphs.get(i.glyph).and_then(|g| offs.get(g.line)).copied().unwrap_or(0.0); + if d != 0.0 { + let m = Affine::translate((0.0, d)); + i.xf = m * i.xf; + i.bounds = m.transform_rect_bbox(i.bounds); + } + } + return; + } + let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end); + // How much of the measured space a pass takes up: halved after a pass that lost text. + let mut step = 1.0; + for _ in 0..8 { + let space = cell_space(&cx.out, regions); + let mut moved = false; + let saved: Vec<(f64, f64)> = regions.iter().map(|r| (r.shift, r.gap)).collect(); + for (r, s) in regions.iter_mut().zip(&space) { + let Some((above, below, n)) = *s else { continue }; + let (shift, gap) = match align { + VerticalAlign::Top => (r.shift, r.gap), + VerticalAlign::Center => (r.shift + (below - above) * 0.5 * step, r.gap), + VerticalAlign::Bottom => (r.shift + below * step, r.gap), + VerticalAlign::Justify if n > 1 => (r.shift, r.gap + below * step / (n - 1) as f64), + VerticalAlign::Justify => (r.shift, r.gap), + }; + let (shift, gap) = (shift.clamp(0.0, r.cell.height().max(0.0)), gap.clamp(0.0, r.cell.height().max(0.0))); + if (shift - r.shift).abs() > 0.25 || (gap - r.gap).abs() > 0.01 { + moved = true; + } + r.shift = shift; + r.gap = gap; + } + if !moved { + return; + } + let before = laid_out(&cx.out); + let prev = ( + std::mem::take(&mut cx.out.glyphs), + std::mem::take(&mut cx.out.lines), + std::mem::take(&mut cx.out.inlines), + std::mem::take(&mut cx.out.markers), + std::mem::replace(&mut cx.out.overflow, false), + ); + flow(cx, paras, t, Some(regions)); + if laid_out(&cx.out) < before { + // Text no longer fits (lines got narrower, or flow around a wrap object): undo the + // pass and try a smaller step. + (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.markers, cx.out.overflow) = prev; + for (r, (shift, gap)) in regions.iter_mut().zip(saved) { + r.shift = shift; + r.gap = gap; + } + step *= 0.5; + if step < 0.1 { + return; + } + } + } +} + +#[cfg(test)] +#[test] +fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_punctuation() { + for c in ['あ', 'カ', '漢', '々', '〆', '〇', 'A'] { + assert!(is_japanese_letter(c), "{c}"); + } + // Hangul in every form: syllables, compatibility jamo (ㄱ ㅏ), half-width jamo (ᄀ). + for c in ['한', 'ㄱ', 'ㅏ', '\u{FFA1}', '。', '「', '・', '、', 'a'] { + assert!(!is_japanese_letter(c), "{c}"); + } +}