//! Line breaking and glyph placement for point, area and on-path type. use std::ops::Range; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points_with; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; use crate::kashida; use crate::shape::{ Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, style_metrics, }; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; const EPS: f64 = 1e-6; struct Ctx<'a> { on_path: bool, db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, default: CharStyle, opts: &'a LayoutOptions, /// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned /// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines /// up as columns. vertical: bool, /// Character panel menu › System Layout. On: positive advances are whole points. system_layout: bool, /// Character panel menu › Fractional Widths. Off, positive advances are whole points. fractional_widths: bool, out: TextLayout, } impl Ctx<'_> { fn style_at(&self, b: usize) -> &CharStyle { let find = |b: usize| self.runs.iter().find(|(r, _)| r.start <= b && b < r.end).map(|(_, s)| *s); find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) } /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges /// from `r.start` with their direction, in order; neighboring runs of one direction make one /// stretch. fn char_directions(&self, r: &Range) -> Vec<(Range, ParaDirection)> { let mut out: Vec<(Range, ParaDirection)> = Vec::new(); for (rr, st) in &self.runs { let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); let Some(d) = st.char_direction.filter(|_| a < b) else { continue }; let (a, b) = (a - r.start, b - r.start); match out.last_mut() { Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b, _ => out.push((a..b, d)), } } out } /// Shape paragraph `r` (its bidi resolution `bidi`). fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); if self.vertical { tate_chu_yoko(&mut v, |g| { let st = self.style_at(g.byte); (st.size, st.tate_chu_yoko) }); // An upright glyph advances down the column by its vertical advance (the font's vertical // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow // mark like § must not overlap its neighbours. for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) { let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx; g.dx += extra * 0.5; g.adv += extra; // Proportional Metrics: `vpal` (and the vertical kerning with it) moves it and // changes its length down the column, in fonts with vertical metrics (else the cell // stays as it is, #966). if let Some(a) = g.vpal.filter(|_| g.tcy.is_none() && g.face.vertical_glyph(g.gid).is_some()) { g.dx += a.dx; g.dy += a.dy; g.adv += a.advance; g.proportional = a.proportional; } } } // System Layout or Fractional Widths off: whole-point advances, after tate-chu-yoko and the // upright cell have set the widths the line breaker will use. A zero advance stays zero. if self.system_layout || !self.fractional_widths { whole_character_widths(&mut v); } v } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let blank = g.is_soft_hyphen() || g.ch.is_control(); // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or // COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps. // Art in the text's colours takes the run's fill (black when it isn't a colour) and // stroke. let plain = g.inline.is_some() || blank; let colour_of = |p: &vectorcraft_doc::color::Paint| match p { vectorcraft_doc::color::Paint::Solid { color, .. } => Some(*color), _ => None, }; let st = self.style_at(g.byte); let foreground = colour_of(&st.fill).unwrap_or(vectorcraft_doc::color::Color::BLACK); let stroke = colour_of(&st.stroke).filter(|_| st.has_stroke()); let (colour, image) = match (!plain).then(|| self.db.glyph_colour(&g.face, g.gid, foreground, stroke)).flatten() { Some(crate::colr::ColourContent::Art(art)) => (Some(art), None), Some(crate::colr::ColourContent::Image(image)) => (None, Some(image)), None => (None, (!plain).then(|| self.db.glyph_image(&g.face, g.gid)).flatten()), }; let src = if g.inline.is_some() || image.is_some() || colour.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); let lead = if upright { 0.0 } else { g.lead }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline). Some(ib) => Affine::translate((-lead, -g.bshift)) * ib.xf, None => { Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy) } }; let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it. let em = self.style_at(g.byte).size; let start = origin.x - t.pen; let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em); let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0)); let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m; } else if self.vertical && stands_upright(g) { // Turned about the centre of its cell, which the column's centre line runs through: the // middle of its own cell, not of its advance (tracking and justification add space // after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; // A leading space taken off moves it up the column after the turn (moving the // turning point instead would move it across the column too). m = Affine::translate((-g.lead, 0.0)) * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))) * m; } let outline = if src.elements().is_empty() || blank { BezPath::new() } else { let mut p = BezPath::with_capacity(src.elements().len()); for el in src.elements() { p.push(m * *el); } p }; let font_id = g.face.id(); if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); } self.out.glyphs.push(PositionedGlyph { outline, run: g.run, byte: g.byte, origin, advance, len: g.len, angle, line, font_id, gid: g.gid, xf: m, // Vertical type keeps its logical order down the column. rtl: g.level.is_rtl() && !self.vertical, scale: 1.0, image, colour, }); } /// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as /// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one /// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel /// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not /// advance: `g`'s advance takes the kashida in. fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) { let Some((gid, t)) = kashida::tatweel(g) else { return }; let mut k = g.clone(); k.gid = gid; k.adv = t; k.dx = 0.0; k.dy = 0.0; k.len = 0; k.lead = 0.0; k.ch = kashida::TATWEEL; k.inline = None; k.tcy = None; if kind == Kashida::Stylistic { let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize; for m in 0..n { let x = at.x + m as f64 * t; self.emit(&k, Affine::translate((x, at.y)), Point::new(x, at.y), 0.0, 0.0, line); } return; } self.emit(&k, Affine::translate((at.x, at.y)) * Affine::scale_non_uniform(len / t, 1.0), at, 0.0, 0.0, line); } } /// 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. let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n); let stretch = glyphs.get(i..end).unwrap_or_default(); i = end.max(i + 1); 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::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.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. let min = if r.rect || r.polys.is_empty() { 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)), _ => 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 take what [`tab_advance`] gives them, not the width of their glyph. 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' { 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. fn word_width(g: &[SGlyph], i: usize) -> f64 { let mut w = 0.0; for gl in g.get(i..).unwrap_or_default() { 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; 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) { 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 > i && !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() && 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::*; use std::sync::Arc; fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { // The face is only a required SGlyph field here; advances and cluster boundaries below are // entirely synthetic, so the regressions don't depend on a particular font's shaping. static DB: std::sync::OnceLock = std::sync::OnceLock::new(); let db = DB.get_or_init(|| FontDb::with_font_dirs(vec![])); let face = db.face("Source Sans 3", "Regular").expect("bundled test face"); specs .iter() .map(|&(byte, len, adv, ch)| SGlyph { face: Arc::clone(&face), gid: 0, byte, len, run: 0, adv, dx: 0.0, dy: 0.0, sx: 1.0, sy: 1.0, bshift: 0.0, rotation: 0.0, ascent: 8.0, descent: 2.0, leading: 0.0, cap: 6.0, xh: 4.0, ch, tcy: None, inline: None, word_end: false, lead: 0.0, proportional: false, vpal: None, level: unicode_bidi::Level::ltr(), no_break: false, }) .collect() } fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range { let first = glyphs.get(start).expect("nonempty source range"); let last = glyphs.get(end - 1).expect("nonempty source range"); first.byte..last.byte + last.len } #[test] fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); assert_eq!(source_range(&glyphs, 0, end), 0..4); assert_eq!(glyphs.get(end).map(|g| g.byte), Some(4)); } #[test] fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); assert_eq!(source_range(&glyphs, first_end, second_end), 4..8); assert_eq!(glyphs.get(second_end).map(|g| g.byte), Some(8)); } #[test] fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { let db = FontDb::global(); let Some(face) = db.face_covering('ب').filter(|face| face.covers('َ')) else { // Arabic integration coverage depends on an installed font; synthetic tests above are // the font-independent regression oracle. return; }; let text = "بَت"; let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; let bidi = para_bidi(text, None); let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels); let mut shaped = Vec::new(); shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &[], &OtFeatures::default(), levels, &mut shaped); let Some(first) = shaped.first() else { return }; let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count(); if first.byte != 0 || first_end < 2 || first_end >= shaped.len() { return; } // Pick a narrow width that fits glyphs before one positive-advance glyph but not that next // glyph. This ensures the old per-glyph loop would have returned inside the first cluster. let mut prefix = shaped.first().map_or(0.0, |g| g.adv); let width = (1..first_end).find_map(|k| { let advance = shaped.get(k)?.adv; if prefix > EPS && advance > EPS { Some(prefix) } else { prefix += advance; None } }); let Some(width) = width else { return }; let mut object = TextObject::point(Point::ZERO, text, style); object.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 100.0).to_path(0.1)) }; let result = layout_with(db, &object, &LayoutOptions::default()); let next_cluster_byte = shaped.get(first_end).map(|g| g.byte); assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte); } /// No Break keeps the marked characters on one line, including across a space, and moves a /// marked run that does not fit to the next line whole. #[test] fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); let mut marked = plain; for g in &mut marked { g.no_break = true; } let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); // 日 and 本 may break between them. Marked, they move together when they do not fit after "A ". let text = "A 日本"; let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); assert_eq!(end, 2, "the marked pair moves to the next line together"); let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); 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(); hyphen_points_with(word, opts) .into_iter() .filter_map(|ci| { let byte = *offs.get(ci)?; // Only at cluster starts (not inside a ligature). (a + 1..b).find(|&k| g[k].byte == byte && g[k - 1].byte != byte) }) .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. fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pen: &Pen<'_>, rtl: 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). let has_tab = sg.iter().any(|g| g.ch == '\t'); 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; while widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.left_indent + if first { 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))) } /// 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, }; 'paras: for (pi, pr) in paras.iter().enumerate() { let para = t.para_at(pi); // 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 compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. let 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 flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); // …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); // 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; loop { let est = if i < n { Metrics::of(&sg[i]) } else { pm }; let first_line = li_para == 0; let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 }; // 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, para.right_indent, 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 - para.right_indent; 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 - para.right_indent) } 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. 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, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).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![]) }; // 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 = sg[i..body].iter().filter(|g| g.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 = sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.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 - para.right_indent + opt_r - w) * 0.5, _ => -para.right_indent + 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; 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' { (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)) }; 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(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() } /// 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(); for pr in paras { 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 glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); 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 }; let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, 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; cx.emit(g, pre, origin, angle, g.adv, 0); 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)); 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) { if let Some(c) = clusters.last_mut() { c.end = i + 1; } } else { clusters.push(i..i + 1); } } let cluster_levels: Vec<_> = clusters .iter() .filter_map(|c| glyphs.get(c.start)) .map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level)) .collect(); unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).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(); 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). let mut by_line: BTreeMap> = BTreeMap::new(); for (i, g) in cx.out.glyphs.iter().enumerate() { let b = g.byte.saturating_add(g.len.max(1)); if g.byte < end && b > start { 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, } }