diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index c6c3587b4..2cdf299ce 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1257,6 +1257,28 @@ fn type_tool_selection_replace_and_delete() { assert_eq!(obj(&s, id).plain_text(), " "); } +/// A Type tool click inside a ligature (Source Serif 4's fi) puts the caret between its +/// characters, and Shift+Right from there selects the i alone. +#[test] +fn type_tool_click_reaches_the_inside_of_a_ligature() { + let mut s = session(); + let v = ViewInfo::default(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "fin", "size": 40, "font": "Source Serif 4"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.select_tool("type", v).unwrap(); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + let (_, f) = g.carets[0]; + let p = t.xf * (g.origin + vectorcraft_geom::Vec2::new(g.advance * f + 0.2, -10.0)); + click(&mut s, p.x, p.y); + assert_eq!(s.tool_options()["caret"], json!(1), "between the f and the i"); + key(&mut s, ToolKey::Right, Mods { shift: true, ..Default::default() }); + assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(1), Some(2))); + s.tool_text("I", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "fIn"); +} + #[test] fn character_panel_flow_styles_the_selected_range_while_editing() { let mut s = session(); diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 0df8c2e33..506c3b1bf 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -239,6 +239,72 @@ impl FontFace { .map(|a| a as f64) .unwrap_or(self.upem * 0.5) } + /// The coordinates of the carets inside ligature glyph `gid`, in font units, in the order the + /// font lists them: the font's ligature caret list (`GDEF` `LigCaretList`, + /// ), one between each two + /// characters the glyph stands for, each an x in horizontal text and a y (up from the baseline) + /// in vertical text. A coordinate with a variation index (format 3 in a variable font) takes its + /// delta from the `GDEF` item variation store at the face's location; a device table's + /// adjustments for pixel sizes are not applied. `None` when the font lists no carets for the + /// glyph, lists a number other than `count`, gives one by a contour point, or gives one a + /// variation index whose delta can't be read. + fn ligature_caret_coordinates(&self, gid: u32, count: usize) -> Option> { + use skrifa::raw::TableProvider; + use skrifa::raw::tables::gdef::CaretValue; + use skrifa::raw::tables::layout::DeviceOrVariationIndex; + use skrifa::raw::tables::variations::DeltaSetIndex; + let font = self.skrifa()?; + let gdef = font.gdef().ok()?; + let list = gdef.lig_caret_list()?.ok()?; + let index = list.coverage().ok()?.get(GlyphId::new(gid))?; + let glyph = list.lig_glyphs().get(usize::from(index)).ok()?; + if usize::from(glyph.caret_count()) != count { + return None; + } + let carets = glyph + .caret_values() + .iter() + .map(|c| match c.ok()? { + CaretValue::Format1(v) => Some(f64::from(v.coordinate())), + CaretValue::Format3(v) => { + let coordinate = f64::from(v.coordinate()); + if v.device_offset().is_null() { + return Some(coordinate); + } + match v.device().ok()? { + DeviceOrVariationIndex::Device(_) => Some(coordinate), + DeviceOrVariationIndex::VariationIndex(ix) => { + let store = gdef.item_var_store()?.ok()?; + let index = DeltaSetIndex { outer: ix.delta_set_outer_index(), inner: ix.delta_set_inner_index() }; + Some(coordinate + store.compute_delta(index, self.location.coords()).ok()?.to_f64()) + } + } + } + CaretValue::Format2(_) => None, + }) + .collect::>>()?; + (!carets.is_empty()).then_some(carets) + } + /// The caret positions inside ligature glyph `gid` set across, in font units from its left + /// edge, left to right: the x coordinates of the font's ligature caret list. `None` when the + /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour + /// point, or gives positions that are not increasing inside the glyph's advance. + pub fn ligature_carets(&self, gid: u32, count: usize) -> Option> { + let carets = self.ligature_caret_coordinates(gid, count)?; + let advance = self.advance(gid); + let inside = carets.iter().all(|x| *x > 0.0 && *x < advance) && carets.windows(2).all(|w| w[0] < w[1]); + inside.then_some(carets) + } + /// The caret positions inside ligature glyph `gid` standing upright in vertical type, in font + /// units down from the top of its cell, top to bottom: the y coordinates of the font's ligature + /// caret list, for a cell `cell` long whose top is `top` above the baseline. `None` when the + /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour + /// point, or gives positions that are not increasing inside the cell. + pub fn vertical_ligature_carets(&self, gid: u32, count: usize, top: f64, cell: f64) -> Option> { + let carets = self.ligature_caret_coordinates(gid, count)?; + let inside = carets.iter().all(|y| *y < top && *y > top - cell) && carets.windows(2).all(|w| w[0] < w[1]); + inside.then(|| carets.iter().rev().map(|y| top - y).collect()) + } /// Glyph `gid` set upright in vertical type, from the font's vertical metrics: (its advance down /// the column, the height above the baseline of its vertical origin, the top of its cell), in /// font units. The origin is the `VORG` table's, else the glyph's top plus its top side bearing. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 67b5c3c7c..cbdd41725 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -37,7 +37,7 @@ use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; -use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, whole_character_widths}; +use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; const EPS: f64 = 1e-6; @@ -47,6 +47,60 @@ fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { prev.zip(g).is_some_and(|(a, b)| a.len > 0 && b.len > 0 && a.byte == b.byte) } +/// The caret stops inside glyph `g` when it alone stands for several characters (grapheme clusters) +/// of `text`, a ligature ([`PositionedGlyph::carets`]); `rtl` when its logical leading edge is its +/// right edge; `upright` when it stands upright in vertical type, its carets then down its cell +/// ([`upright_extent`]). Empty for a glyph of one character. +fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usize, f64)> { + use unicode_segmentation::UnicodeSegmentation; + let Some(s) = text.get(g.byte..g.byte + g.len).filter(|_| g.len > 1) else { return Vec::new() }; + let starts: Vec = s.grapheme_indices(true).skip(1).map(|(i, _)| g.byte + i).collect(); + if starts.is_empty() { + return Vec::new(); + } + let parts = starts.len() + 1; + let from_font = if upright { + let (cell, top) = upright_extent(g); + g.face + .vertical_ligature_carets(g.gid, starts.len(), top, cell) + .filter(|c| c.len() == starts.len() && cell > 0.0) + .map(|c| c.into_iter().map(|d| d / cell).collect::>()) + } else { + let advance = g.face.advance(g.gid); + g.face + .ligature_carets(g.gid, starts.len()) + .filter(|c| c.len() == starts.len() && advance > 0.0) + .map(|c| c.into_iter().map(|x| if rtl { 1.0 - x / advance } else { x / advance }).collect::>()) + }; + let mut at = from_font.unwrap_or_else(|| (1..parts).map(|j| j as f64 / parts as f64).collect()); + at.sort_by(f64::total_cmp); + starts.into_iter().zip(at).collect() +} + +/// Does glyph `g`, standing upright in vertical type, take a cell of its own down the column? Every +/// glyph with an advance does, including one whose tracking or kerning makes its advance zero or +/// negative; a glyph without a font advance (a mark) does not. +fn takes_cell(g: &SGlyph) -> bool { + g.adv > 0.0 || g.face.advance(g.gid) > 0.0 +} + +/// Clear the caret stops of each glyph that shares its cluster with another glyph: only a glyph +/// that alone stands for its characters is a ligature. +fn keep_lone_ligature_carets(glyphs: &mut [PositionedGlyph]) { + if glyphs.iter().all(|g| g.carets.is_empty()) { + return; + } + let mut count: std::collections::HashMap<(usize, usize), usize> = std::collections::HashMap::new(); + for g in glyphs.iter().filter(|g| g.len > 0) { + *count.entry((g.byte, g.len)).or_default() += 1; + } + for g in glyphs.iter_mut().filter(|g| !g.carets.is_empty()) { + if count.get(&(g.byte, g.len)).is_some_and(|n| *n > 1) { + g.carets.clear(); + } + } +} + struct Ctx<'a> { on_path: bool, db: &'a FontDb, @@ -278,7 +332,7 @@ impl Ctx<'_> { // 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 }; + let cell = if takes_cell(g) { 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). let turn = Affine::translate((-g.lead, 0.0)) @@ -300,6 +354,29 @@ impl Ctx<'_> { p }; let font_id = g.face.id(); + // Its own width along the baseline: its font advance through its size, scale and the glyph + // scaling `pre` adds. In vertical type, an upright glyph's own length down the column is + // its cell with the Proportional Metrics change to it, and a tate-chu-yoko glyph takes its + // advance, as an inline graphic does. + let upright = self.vertical && g.inline.is_none() && g.tcy.is_none() && takes_cell(g) && stands_upright(g); + let width = if g.inline.is_some() || (self.vertical && g.tcy.is_some()) { + advance + } else if upright { + let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance); + Some(upright_cell(g) + proportional).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(advance) + } else { + let [a, b, ..] = pre.as_coeffs(); + Some(g.face.advance(g.gid) * g.sx * a.hypot(b)).filter(|w| w.is_finite()).unwrap_or(advance) + }; + // Where its own width starts along its advance: the shaper's placement, a leading space + // taken off and right-to-left manual kerning move the glyph along the line. An upright or + // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance. + let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + let [a, b, ..] = pre.as_coeffs(); + Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0) + } else { + 0.0 + }; 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) }); } @@ -323,6 +400,9 @@ impl Ctx<'_> { bend: None, unturned, pivot, + carets: if g.inline.is_some() { Vec::new() } else { ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, upright) }, + width, + width_start, }); } diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 0b76f2c5a..01fef1765 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -5,6 +5,7 @@ use super::ruby::ruby_layout; use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds}; use super::{ Affine, BezPath, CharStyle, Ctx, FontDb, LayoutOptions, OtFeatures, Point, Rect, Shape, TextKind, TextLayout, TextObject, VerticalAlign, + keep_lone_ligature_carets, }; /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). @@ -305,6 +306,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout cx.out.vertical = true; cx.out.line_xf = line_xf; } + keep_lone_ligature_carets(&mut cx.out.glyphs); finish_bounds(&mut cx.out); cx.out } diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 59ce52c4c..1cf34e24c 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -369,5 +369,8 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi bend: None, unturned: Affine::IDENTITY, pivot: m * g.origin, + carets: Vec::new(), + width: 0.0, + width_start: 0.0, } } diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index e571953d8..8f01a5d32 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -206,6 +206,20 @@ pub(super) fn upright_cell(g: &SGlyph) -> f64 { } } +/// An upright glyph's cell in font units, as [`upright_cell`] and [`upright_centre`] place it before +/// its size: (its length down the column, the height of its top above the baseline). From the +/// font's vertical metrics, else at least one em long, centred on the ideographic em box. +pub(super) fn upright_extent(g: &SGlyph) -> (f64, f64) { + match g.face.vertical_glyph(g.gid) { + Some((advance, origin)) => (advance, origin), + None => { + let em = g.face.units_per_em(); + let cell = g.face.advance(g.gid).max(em); + (cell, EM_CENTER * em + cell * 0.5) + } + } +} + /// 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`). diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index a1b77e02a..bafaf70af 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -149,6 +149,25 @@ pub struct PositionedGlyph { /// Font units: `xf * pivot` is the point its Character Rotation turns it about, its pen /// position on the baseline moved by its baseline shift. pub pivot: Point, + /// A ligature (one glyph that alone stands for several characters, grapheme clusters, of the + /// text): the caret stop between each two of them, in logical order, as the byte offset of the + /// later character and how far the caret stands along the glyph's own width ([`Self::width`]) + /// from its logical leading edge, 0 to 1. The font's ligature carets + /// ([`FontFace::ligature_carets`], or [`FontFace::vertical_ligature_carets`] for a glyph + /// standing upright in vertical type) when it lists one for each stop, else the width divided + /// equally among the characters. Empty for any other glyph. + pub carets: Vec<(usize, f64)>, + /// The glyph's own width along its advance in text space: its font advance at its size, + /// Horizontal Scale and glyph scaling, without the tracking, kerning, justification space and + /// kashida that [`Self::advance`] includes after it (to its right in horizontal type). The + /// advance itself in vertical type. + pub width: f64, + /// How far right of the left end of its advance the glyph's own width ([`Self::width`]) starts, + /// in text space: the font's placement of the glyph (`GPOS` x placement), less a leading space + /// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that + /// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the + /// column in vertical type, and for an inline graphic. + pub width_start: f64, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its @@ -167,6 +186,57 @@ pub struct GlyphBend { } impl PositionedGlyph { + /// The glyph's caret stops in logical order: its first byte, its ligature carets + /// ([`Self::carets`]) and its end, each with how far along its own width ([`Self::width`]) from + /// its logical leading edge the caret stands, 0 to 1. + fn width_stops(&self) -> impl Iterator + '_ { + std::iter::once((self.byte, 0.0)).chain(self.carets.iter().copied()).chain(std::iter::once((self.byte + self.len, 1.0))) + } + /// The glyph's caret stops in logical order, each with its distance in text space from the + /// logical leading edge of a span `extent` long that holds the glyph (its advance, or its + /// cluster's): 0 at its first byte and `extent` at its end. A ligature caret stands at its place + /// in the glyph's own width ([`Self::width`]), which starts [`Self::width_start`] right of the + /// glyph's left edge; the rest of the space the advance adds lies to the right of that width, + /// after the last ligature caret left to right, and before the first one right to left. + /// Negative kerning or tracking makes the advance shorter than the width, or zero or negative, + /// and a ligature caret then stands past the end of the advance. + fn stops_along(&self, extent: f64) -> impl Iterator + '_ { + let (width, start) = if self.width > 0.0 && self.width.is_finite() { + (self.width, if self.width_start.is_finite() { self.width_start } else { 0.0 }) + } else { + (extent, 0.0) + }; + let ends = (self.byte, self.byte + self.len); + self.width_stops().map(move |(b, f)| { + if b == ends.0 { + (b, 0.0) + } else if b == ends.1 { + (b, extent) + } else if self.rtl { + (b, extent - start - (1.0 - f) * width) + } else { + (b, start + f * width) + } + }) + } + /// The distance in text space from the logical leading edge of a span `extent` long that holds + /// the glyph (its advance, or its cluster's) to the caret for byte `byte`: 0 at its first byte, + /// `extent` at its end, a ligature caret's place at that caret's byte ([`Self::stops_along`], + /// which can lie outside 0 to `extent`), and in proportion to the bytes between two stops. + pub fn caret_offset(&self, byte: usize, extent: f64) -> f64 { + if byte <= self.byte || self.len == 0 { + return 0.0; + } + let mut from = (self.byte, 0.0); + for (b, d) in self.stops_along(extent).skip(1) { + if byte <= b { + let span = b.saturating_sub(from.0).max(1) as f64; + return from.1 + (d - from.1) * (byte - from.0) as f64 / span; + } + from = (b, d); + } + extent + } /// The glyph's colour bitmap, and the transform from its pixels to text space. pub fn placed_image(&self) -> Option<(&Arc, Affine)> { self.image.as_ref().map(|i| (i, self.xf * i.xf)) @@ -436,7 +506,8 @@ fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> } /// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a -/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). +/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). Inside a ligature the +/// caret stands at its caret stops ([`PositionedGlyph::carets`]). pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { let Some(line) = layout.lines.get(layout.line_of(byte)) else { return (Point::ZERO, Point::ZERO); @@ -444,12 +515,12 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; let (pos, angle) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { let g = &glyphs[i]; - let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; let (origin, advance) = match cluster_extent(layout, glyphs, i) { Some((left, right)) => (Point::new(left, g.origin.y), right - left), None => (g.origin, g.advance), }; - (origin + dir(g.angle) * (advance * if g.rtl { 1.0 - frac } else { frac }), g.angle) + let off = g.caret_offset(byte, advance); + (origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle) } else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) { (g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle) } else { @@ -464,11 +535,16 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { /// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool). pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { if layout.on_path { - let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| { - let ca = a.origin + dir(a.angle) * (a.advance * 0.5); - let cb = b.origin + dir(b.angle) * (b.advance * 0.5); - (p - ca).hypot2().total_cmp(&(p - cb).hypot2()) - }); + // The distance from `p` to a glyph's middle, or to one of its inner ligature caret stops + // when that is nearer (a stop can lie past the glyph's advance). + let near = |g: &PositionedGlyph| { + let along = dir(g.angle); + let mid = (p - (g.origin + along * (g.advance * 0.5))).hypot2(); + (g.stops_along(g.advance).filter(|(b, _)| *b != g.byte && *b != g.byte + g.len)) + .map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2()) + .fold(mid, f64::min) + }; + let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))); let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) }; // `first` and `last` are the first and last glyphs of the cluster. A click before the middle of // the cluster, measured along the first glyph's direction, goes to the cluster's start. @@ -476,6 +552,14 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g); let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g); let along = dir(first.angle); + if !g.carets.is_empty() { + // A ligature: the nearest of its caret stops, measured along it from its logical + // leading edge in text space, within the span its stops cover. + let (lo, hi) = g.stops_along(g.advance).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, d)| (lo.min(d), hi.max(d))); + let t = (p - g.origin).dot(along); + let at = (if g.rtl { g.advance - t } else { t }).max(lo).min(hi); + return g.stops_along(g.advance).min_by(|a, b| (a.1 - at).abs().total_cmp(&(b.1 - at).abs())).map_or(g.byte, |s| s.0); + } let span = (last.origin + dir(last.angle) * last.advance - first.origin).dot(along); return if ((p - first.origin).dot(along) < span * 0.5) != g.rtl { g.byte } else { g.byte + g.len }; } @@ -517,7 +601,8 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { // a consonant and its reph) has one caret stop at each end of its glyphs taken together. The // tatweels of a kashida have no source length or advance and come after the glyphs of the // letter after the kashida in the text; they belong to that letter's cluster. A list marker's - // glyphs (no source length, an advance) are a cluster of their own. + // glyphs (no source length, an advance) are a cluster of their own. A ligature also has a stop + // at each of its caret stops. let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); for g in glyphs { let left = layout.logical_point(g.origin).x; @@ -529,7 +614,7 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { } let nearest = clusters .iter() - .flat_map(|&(left, right, g)| if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }) + .flat_map(|&(left, right, g)| g.stops_along(right - left).map(move |(b, d)| (if g.rtl { right - d } else { left + d }, b))) .min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs())); nearest.map_or(line.start, |(_, byte)| byte) } @@ -562,9 +647,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { let g = &glyphs[i]; - let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; let (left, advance) = cluster_extent(layout, glyphs, i).map_or((layout.logical_point(g.origin).x, g.advance), |(l, r)| (l, r - l)); - return left + advance * if g.rtl { 1.0 - frac } else { frac }; + let off = g.caret_offset(byte, advance); + return left + if g.rtl { advance - off } else { off }; } glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance }) } @@ -592,13 +677,25 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 if a == b { return out; } + // The part of glyph `g` that the selection covers, as distances in text space from its left + // (its start along a path), the smaller first: all of its advance, or the characters of a + // ligature that are selected. A ligature caret past a shortened advance lies outside it. + let covered = |g: &PositionedGlyph| { + if g.len == 0 { + return (g.advance.min(0.0), g.advance.max(0.0)); + } + let (fa, fb) = (g.caret_offset(a.max(g.byte), g.advance), g.caret_offset(b.min(g.byte + g.len), g.advance)); + let (f0, f1) = if g.rtl { (g.advance - fb, g.advance - fa) } else { (fa, fb) }; + (f0.min(f1), f0.max(f1)) + }; if layout.on_path { // A list marker's glyphs stand for no characters. - for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b && g.len > 0) { + for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a && g.len > 0) { let li = &layout.lines[0]; let d = dir(g.angle); let up = Vec2::new(d.y, -d.x); - let (p0, p1) = (g.origin, g.origin + d * g.advance); + let (f0, f1) = covered(g); + let (p0, p1) = (g.origin + d * f0, g.origin + d * f1); out.push([p0 + up * li.ascent, p1 + up * li.ascent, p1 - up * li.descent, p0 - up * li.descent]); } return out; @@ -609,7 +706,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 for (_, g) in glyphs { let Some(l) = layout.lines.get(g.line) else { continue }; let p = layout.logical_point(g.origin); - let (x0, x1) = (p.x, p.x + g.advance); + let (f0, f1) = covered(g); + let (x0, x1) = (p.x + f0, p.x + f1); out.push( [ Point::new(x0, l.baseline - l.ascent), @@ -636,6 +734,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance); xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25; } + // A ligature caret past the ligature's shortened advance stands right of the glyph's end. + let (xa, xb) = (xa.min(xb), xa.max(xb)); if xb - xa <= 1e-9 { continue; } @@ -678,7 +778,7 @@ fn caret_horizontal_filtered(layout: &TextLayout, byte: usize, right: bool, vali (layout.logical_point(caret_position(layout, b).0).x, if rtl { -(b as f64) } else { b as f64 }) }; let (x, order) = stop(byte); - let mut stops: Vec<_> = glyphs.iter().flat_map(|g| [g.byte, g.byte + g.len]).collect(); + let mut stops: Vec<_> = glyphs.iter().flat_map(|g| g.width_stops().map(|(b, _)| b)).collect(); stops.sort_unstable(); stops.dedup(); stops diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index a4e0fcde6..2a6072762 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -489,6 +489,195 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { Some(builder.build()) } +/// The family of [`ligature_caret_font`]. +pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; + +/// The glyph [`ligature_caret_font`]'s `liga` gives for f + i: W, wider than either letter. +pub const LIGATURE_CARET_GLYPH: char = 'W'; + +/// A test-only cmap over bundled Source Sans 3 for f, i and the space, whose `GSUB` `liga` joins f + +/// i into [`LIGATURE_CARET_GLYPH`]. With `caret`, a `GDEF` ligature caret list puts that glyph's one +/// caret at x = `caret` font units; without it the font has no `GDEF` table. `None` when it can't be +/// built. +pub fn ligature_caret_font(caret: Option) -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gdef::{CaretValue, Gdef, LigCaretList, LigGlyph}; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('f', 'f'), ('i', 'i'), (' ', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = LIGATURE_CARET_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Carettest Sans Regular"), (6, "CarettestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let ligature = gid(LIGATURE_CARET_GLYPH)?; + let subst = LigatureSubstFormat1::new( + CoverageFormat1::new(vec![gid('f')?]).into(), + vec![LigatureSet::new(vec![Ligature::new(ligature, vec![gid('i')?])])], + ); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"liga"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + if let Some(x) = caret { + let carets = LigCaretList::new(CoverageFormat1::new(vec![ligature]).into(), vec![LigGlyph::new(vec![CaretValue::format_1(x)])]); + builder.add_table(&Gdef::new(None, None, Some(carets), None)).ok()?; + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GDEF") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`vertical_ligature_caret_font`]. +pub const VERTICAL_LIGATURE_CARET_FAMILY: &str = "Vertical Carettest Sans"; + +/// [`ligature_caret_font`] with its one caret at y = `caret` font units, as family +/// [`VERTICAL_LIGATURE_CARET_FAMILY`], with vertical metrics (`vhea`, `vmtx` and `VORG`): every glyph +/// one em down the column with its vertical origin at 0.88 em. `None` when it can't be built. +pub fn vertical_ligature_caret_font(caret: i16) -> Option> { + let base = ligature_caret_font(Some(caret))?; + let font = skrifa::FontRef::new(&base).ok()?; + let n = font.maxp().ok()?.num_glyphs(); + let be16 = |v: &mut Vec, x: i16| v.extend_from_slice(&x.to_be_bytes()); + // vmtx: (advance height, top side bearing) for every glyph; VORG gives the origin. + let mut vmtx = Vec::with_capacity(4 * usize::from(n)); + for _ in 0..n { + vmtx.extend_from_slice(&1000_u16.to_be_bytes()); + be16(&mut vmtx, 0); + } + // vhea 1.1: ascent/descent of the em box across the column, then the metrics' count. + let mut vhea = Vec::with_capacity(36); + vhea.extend_from_slice(&0x0001_1000_u32.to_be_bytes()); + for x in [500, -500, 0] { + be16(&mut vhea, x); + } + vhea.extend_from_slice(&1000_u16.to_be_bytes()); + for x in [0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0] { + be16(&mut vhea, x); + } + vhea.extend_from_slice(&n.to_be_bytes()); + // VORG 1.0: the default origin and no glyph that differs. + let mut vorg = Vec::new(); + for x in [1, 0, 880, 0] { + be16(&mut vorg, x); + } + let family = VERTICAL_LIGATURE_CARET_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Vertical Carettest Sans Regular"), (6, "VerticalCarettestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_raw(Tag::new(b"vhea"), vhea); + builder.add_raw(Tag::new(b"vmtx"), vmtx); + builder.add_raw(Tag::new(b"VORG"), vorg); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`variable_ligature_caret_font`]. +pub const VARIABLE_LIGATURE_CARET_FAMILY: &str = "Varicaret Sans"; + +/// [`ligature_caret_font`] as a variable font of family [`VARIABLE_LIGATURE_CARET_FAMILY`]: one +/// weight axis (400–700, default 400) with the named instances Regular (400) and Bold (700), and a +/// `GDEF` ligature caret list whose one caret is a format 3 value at x = `caret` font units with a +/// variation index into the `GDEF` item variation store that moves it by `delta` at 700. `None` +/// when it can't be built. +pub fn variable_ligature_caret_font(caret: i16, delta: i16) -> Option> { + use write_fonts::tables::gdef::{CaretValue, Gdef, LigCaretList, LigGlyph}; + use write_fonts::tables::layout::{CoverageFormat1, DeviceOrVariationIndex}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::GlyphId16; + + let base = ligature_caret_font(None)?; + let font = skrifa::FontRef::new(&base).ok()?; + let ligature = GlyphId16::new(source_sans_gid(LIGATURE_CARET_GLYPH)?); + let value = CaretValue::format_3(caret, DeviceOrVariationIndex::variation_index(0, 0)); + let carets = LigCaretList::new(CoverageFormat1::new(vec![ligature]).into(), vec![LigGlyph::new(vec![value])]); + let mut gdef = Gdef::new(None, None, Some(carets), None); + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), F2Dot14::from_f32(1.0), F2Dot14::from_f32(1.0))]); + // One delta set of one 16-bit delta for the one region. + let data = ItemVariationData::new(1, 1, vec![0], delta.to_be_bytes().to_vec()); + gdef.item_var_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into(); + let axis = VariationAxisRecord { + axis_tag: Tag::new(b"wght"), + min_value: Fixed::from_i32(400), + default_value: Fixed::from_i32(400), + max_value: Fixed::from_i32(700), + flags: 0, + axis_name_id: NameId::new(256), + }; + let instance = |name: u16, wght: i32| InstanceRecord { + subfamily_name_id: NameId::new(name), + flags: 0, + coordinates: vec![Fixed::from_i32(wght)], + post_script_name_id: None, + }; + let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], vec![instance(257, 400), instance(258, 700)])); + let family = VARIABLE_LIGATURE_CARET_FAMILY; + let name = name_table(&[ + (1, family), + (2, "Regular"), + (4, "Varicaret Sans Regular"), + (6, "VaricaretSans-Regular"), + (256, "Weight"), + (257, "Regular"), + (258, "Bold"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&gdef).ok()?.add_table(&fvar).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// [`ligature_caret_font`] with its one caret at x = `caret` font units and a `GPOS` `kern` single +/// adjustment that places the ligature glyph `x` font units right of its pen position. `None` when +/// it can't be built. +pub fn placed_ligature_caret_font(caret: i16, x: i16) -> Option> { + use write_fonts::tables::gpos::{Gpos, PositionLookup, PositionLookupList, SinglePos, ValueRecord}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = ligature_caret_font(Some(caret))?; + let font = skrifa::FontRef::new(&base).ok()?; + let ligature = GlyphId16::new(source_sans_gid(LIGATURE_CARET_GLYPH)?); + let pos = SinglePos::format_1(CoverageFormat1::new(vec![ligature]).into(), ValueRecord::new().with_x_placement(x)); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"kern"), Feature::new(None, vec![0]))]); + let lookups = PositionLookupList::new(vec![PositionLookup::Single(Lookup::new(Default::default(), vec![pos]))]); + let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]); + let mut builder = FontBuilder::new(); + builder.add_table(&Gpos::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index db28b2013..3f86a85bd 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -15,6 +15,7 @@ mod hang; mod inline; mod joiners; mod justify; +mod ligature_carets; mod path_joins; mod ruby; mod scripts; diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index 7e4931297..d7cd58401 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -165,6 +165,9 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout bend: None, unturned: Affine::IDENTITY, pivot: Point::new(x, baseline), + carets: Vec::new(), + width: advance, + width_start: 0.0, }); x += advance; } diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs new file mode 100644 index 000000000..ee42864c5 --- /dev/null +++ b/crates/text/src/tests/ligature_carets.rs @@ -0,0 +1,498 @@ +//! Caret stops inside ligatures: the caret, a click, arrow keys and the selection reach each +//! character a ligature glyph stands for, at the font's ligature carets (`GDEF`) or, without them, +//! at the glyph's advance divided equally. + +use kurbo::{BezPath, Point, Vec2}; +use vectorcraft_doc::{CharStyle, TextKind, TextObject}; +use vectorcraft_geom::PathData; + +use crate::test_fonts::{ + LIGATURE_CARET_FAMILY, LIGATURE_CARET_GLYPH, VARIABLE_LIGATURE_CARET_FAMILY, VERTICAL_LIGATURE_CARET_FAMILY, lam_alef_family, lam_alef_font, + ligature_caret_font, placed_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, vertical_ligature_caret_font, +}; +use crate::*; + +/// A database of the bundled fonts and `font`. +fn db_with(font: Option>) -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(font.expect("the test font builds")), 1); + db +} + +fn point(text: &str, family: &str) -> TextObject { + TextObject::point(Point::ZERO, text, CharStyle { font_family: family.into(), size: 100.0, ..CharStyle::default() }) +} + +/// The caret's x for byte `b`. +fn caret_x(l: &TextLayout, b: usize) -> f64 { + caret_position(l, b).0.x +} + +/// The x of a click at `at` (0 to 1) along glyph `g`'s advance from its left, on its baseline. +fn along(g: &PositionedGlyph, at: f64) -> Point { + Point::new(g.origin.x + g.advance * at, g.origin.y) +} + +/// A font whose caret list for a ligature holds a number of carets other than the one asked for +/// gives none. +#[test] +fn a_caret_list_of_another_length_gives_no_carets() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + assert_eq!(face.ligature_carets(gid, 1), Some(vec![150.0])); + assert_eq!(face.ligature_carets(gid, 2), None); + assert_eq!(face.ligature_carets(gid, 0), None); +} + +/// A variable font's ligature caret with a variation index moves with the face's axis settings: by +/// none at the default weight, by the whole delta at the weight its region peaks at. +#[test] +fn a_variable_ligature_caret_takes_its_delta() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(variable_ligature_caret_font(150, 100).expect("the test font builds")), 2, "Regular and Bold"); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let regular = db.face(VARIABLE_LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let bold = db.face(VARIABLE_LIGATURE_CARET_FAMILY, "Bold").expect("the test font is added"); + assert_eq!(regular.ligature_carets(gid, 1), Some(vec![150.0])); + assert_eq!(bold.ligature_carets(gid, 1), Some(vec![250.0])); +} + +/// The font's ligature caret is where the caret, a click, arrow keys and the selection stop inside +/// the ligature, not the middle of the glyph. +#[test] +fn the_font_ligature_caret_places_the_inner_stop() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let l = layout(&db, &point("fi", LIGATURE_CARET_FAMILY)); + assert_eq!(l.glyphs.len(), 1, "f + i is one glyph: {:?}", l.glyphs); + let g = &l.glyphs[0]; + assert_eq!((g.gid, g.len), (gid, 2)); + let at = 150.0 / face.advance(gid); + assert!(at < 0.4, "the caret is well left of the middle: {at}"); + assert_eq!(g.carets.len(), 1); + assert_eq!(g.carets[0].0, 1); + assert!((g.carets[0].1 - at).abs() < 1e-9, "{:?} against {at}", g.carets); + assert!((caret_x(&l, 1) - g.advance * at).abs() < 1e-6); + // A click left or right of the caret goes to it rather than to either end. + assert_eq!(hit_byte(&l, along(g, at * 0.4)), 0); + assert_eq!(hit_byte(&l, along(g, at * 1.4)), 1); + assert_eq!(hit_byte(&l, along(g, 0.9)), 2); + // Arrow keys stop at it both ways, and the f alone is selected up to it. + assert_eq!(caret_horizontal(&l, 0, true), 1); + assert_eq!(caret_horizontal(&l, 1, true), 2); + assert_eq!(caret_horizontal(&l, 2, false), 1); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1); + assert!((q[0][1].x - q[0][0].x - g.advance * at).abs() < 1e-6, "{q:?}"); +} + +/// The font's placement of a ligature (`GPOS` x placement) moves its caret with the glyph. +#[test] +fn a_placed_ligature_keeps_its_caret_on_the_glyph() { + let at = |font: Option>| { + let db = db_with(font); + let l = layout(&db, &point("fi", LIGATURE_CARET_FAMILY)); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph").clone(); + (caret_x(&l, 1) - g.origin.x, g.outline.bounding_box().x0 - g.origin.x, g.width_start, hit_byte(&l, Point::new(caret_x(&l, 1) + 1.0, 0.0))) + }; + let (caret, ink, start, _) = at(ligature_caret_font(Some(150))); + let (placed_caret, placed_ink, placed_start, hit) = at(placed_ligature_caret_font(150, 100)); + // 100 units at 100 pt in a 1000-unit em. + assert!(start.abs() < 1e-9 && (placed_start - 10.0).abs() < 1e-9, "{start} {placed_start}"); + assert!((placed_ink - ink - 10.0).abs() < 1e-6, "the glyph moves right: {placed_ink} against {ink}"); + assert!((placed_caret - caret - 10.0).abs() < 1e-6, "the caret moves with it: {placed_caret} against {caret}"); + assert_eq!(hit, 1); +} + +/// A ligature whose font lists no carets for it divides its advance equally among its characters. +#[test] +fn a_ligature_without_font_carets_is_divided_equally() { + let db = db_with(ligature_caret_font(None)); + let l = layout(&db, &point("fi", LIGATURE_CARET_FAMILY)); + assert_eq!(l.glyphs.len(), 1); + let g = &l.glyphs[0]; + assert_eq!(g.carets, vec![(1, 0.5)]); + assert!((caret_x(&l, 1) - g.advance * 0.5).abs() < 1e-6); + assert_eq!(hit_byte(&l, along(g, 0.4)), 1); + assert_eq!(hit_byte(&l, along(g, 0.2)), 0); + // A caret value outside the glyph is not used either. + let db = db_with(ligature_caret_font(Some(-20))); + let l = layout(&db, &point("fi", LIGATURE_CARET_FAMILY)); + assert_eq!(l.glyphs[0].carets, vec![(1, 0.5)]); +} + +/// Tracking adds space after a ligature's own width: the font's caret stays where the font puts it +/// in the glyph, and the caret, a click and the selection use that place, left to right and right +/// to left. +#[test] +fn tracking_does_not_move_a_ligature_caret() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let mut t = point("fi", LIGATURE_CARET_FAMILY); + t.runs[0].style.tracking = 50.0; + let l = layout(&db, &t); + assert_eq!(l.glyphs.len(), 1, "f + i stays one glyph at this tracking: {:?}", l.glyphs); + let g = &l.glyphs[0]; + let k = 100.0 / face.units_per_em(); + let width = face.advance(g.gid) * k; + assert!((g.width - width).abs() < 1e-9, "{} against {width}", g.width); + assert!((g.advance - width - 5.0).abs() < 1e-9, "the advance takes in 5 pt of tracking: {}", g.advance); + let x = g.origin.x + 150.0 * k; + assert!((caret_x(&l, 1) - x).abs() < 1e-6, "{} against {x}", caret_x(&l, 1)); + assert_eq!(hit_byte(&l, Point::new(g.origin.x + 150.0 * k * 0.45, -10.0)), 0); + assert_eq!(hit_byte(&l, Point::new(x + 0.3, -10.0)), 1); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1); + assert!((q[0][1].x - x).abs() < 1e-6, "{q:?}"); + // Right to left the tracking lies to the glyph's right, before its first character: the stop + // between lam and alef stays in the middle of the glyph's own width. + let db = db_with(lam_alef_font(b"rlig")); + let mut t = point("\u{0644}\u{0627}", lam_alef_family(b"rlig")); + t.runs[0].style.tracking = 50.0; + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 4).expect("lam + alef is one glyph"); + assert!(g.rtl && g.advance > g.width + 4.0, "{} {}", g.advance, g.width); + let mid = g.origin.x + g.width * 0.5; + assert!((caret_x(&l, 2) - mid).abs() < 1e-6, "{} against {mid}", caret_x(&l, 2)); + let q = selection_quads(&l, 2, 4); + assert_eq!(q.len(), 1, "{q:?}"); + let (x0, x1) = (q[0][0].x.min(q[0][1].x), q[0][0].x.max(q[0][1].x)); + assert!((x0 - g.origin.x).abs() < 1e-6 && (x1 - mid).abs() < 1e-6, "{q:?}"); +} + +/// Negative kerning can end a ligature's advance before its font caret: the caret still stands at +/// the font's place, a click at the advance's end goes to the end of the ligature, and the last +/// character's selection runs between the two. +#[test] +fn negative_kerning_keeps_a_ligature_caret_past_the_advance() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let k = 100.0 / face.units_per_em(); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let width = face.advance(gid) * k; + // Kerning (thousandths of an em at 100 pt, a tenth of a point each) that leaves an advance of + // half the distance to the font caret. + let kern = (150.0 * k * 0.5 - width) * 10.0; + let mut t = point("fi", LIGATURE_CARET_FAMILY); + t.runs[0].style.kerning = Some(kern); + let l = layout(&db, &t); + assert_eq!(l.glyphs.len(), 1, "f + i stays one glyph with this kerning: {:?}", l.glyphs); + let g = &l.glyphs[0]; + assert!((g.width - width).abs() < 1e-9, "{} against {width}", g.width); + assert!((g.advance - 150.0 * k * 0.5).abs() < 1e-6, "the advance ends before the caret: {}", g.advance); + let x = g.origin.x + 150.0 * k; + let end = g.origin.x + g.advance; + assert!((caret_x(&l, 1) - x).abs() < 1e-6, "{} against {x}", caret_x(&l, 1)); + assert!((caret_x(&l, 2) - end).abs() < 1e-6, "{} against {end}", caret_x(&l, 2)); + assert_eq!(hit_byte(&l, Point::new(end, -10.0)), 2); + assert_eq!(hit_byte(&l, Point::new(x + 0.3, -10.0)), 1); + let q = selection_quads(&l, 1, 2); + assert_eq!(q.len(), 1, "{q:?}"); + let (x0, x1) = (q[0][0].x.min(q[0][1].x), q[0][0].x.max(q[0][1].x)); + assert!((x0 - end).abs() < 1e-6 && (x1 - x).abs() < 1e-6, "{q:?}"); + // On a path, a click at the advance's end goes to the end of the ligature as well. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + let d = Vec2::new(g.angle.cos(), g.angle.sin()); + assert_eq!(hit_byte(&l, g.origin + d * g.advance), 2); + assert_eq!(hit_byte(&l, g.origin + d * (g.width * 150.0 / face.advance(gid) + 0.3)), 1); +} + +/// On a path, a ligature whose negative kerning ends its advance before its font caret is followed +/// by an i that covers the caret: a click at the caret goes to the caret, not to the i. +#[test] +fn a_click_at_a_ligature_caret_past_the_advance_on_a_path_reaches_it() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let k = 100.0 / face.units_per_em(); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let width = face.advance(gid) * k; + let kern = (150.0 * k * 0.5 - width) * 10.0; + let mut t = point("fii", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut t.runs, 0, 2, |st| st.kerning = Some(kern)); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + let i = l.glyphs.iter().find(|h| h.byte == 2).expect("the second i"); + let d = Vec2::new(g.angle.cos(), g.angle.sin()); + let caret = g.origin + d * (g.width * 150.0 / face.advance(gid)); + let mid = |h: &PositionedGlyph| h.origin + d * (h.advance * 0.5); + assert!((caret - mid(i)).hypot() < (caret - mid(g)).hypot(), "the i's middle is nearer the caret than the ligature's"); + assert_eq!(hit_byte(&l, caret + d * 0.3), 1); +} + +/// Tracking of -1000 (one em to the left) leaves Source Serif 4's fi ligature, with standard +/// ligatures turned on explicitly, an advance of zero or less: the caret, a click and the selection +/// still reach the i at its place in the glyph's own width, across and on a path. +#[test] +fn a_ligature_without_advance_keeps_its_inner_stop() { + let db = FontDb::with_font_dirs(vec![]); + let mut t = point("fi", "Source Serif 4"); + t.runs[0].style.tracking = -1000.0; + t.runs[0].style.features = vec!["liga".into()]; + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + assert!(g.advance <= 0.0 && g.width > 0.0, "{} {}", g.advance, g.width); + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + let x = g.origin.x + g.width * f; + let end = g.origin.x + g.advance; + assert!((caret_x(&l, 1) - x).abs() < 1e-6, "{} against {x}", caret_x(&l, 1)); + assert!((caret_x(&l, 2) - end).abs() < 1e-6, "{} against {end}", caret_x(&l, 2)); + assert_eq!(hit_byte(&l, Point::new(x + 0.3, -10.0)), 1); + assert_eq!(hit_byte(&l, Point::new(g.origin.x + 0.3, -10.0)), 0); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + let (x0, x1) = (q[0][0].x.min(q[0][1].x), q[0][0].x.max(q[0][1].x)); + assert!((x0 - g.origin.x).abs() < 1e-6 && (x1 - x).abs() < 1e-6, "{q:?} against {} {x}", g.origin.x); + assert_eq!(caret_horizontal(&l, 0, true), 1); + // On a path, a click past the inner stop goes to it, and the f alone is selected up to it. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + assert!(g.advance <= 0.0, "{}", g.advance); + let d = Vec2::new(g.angle.cos(), g.angle.sin()); + assert_eq!(hit_byte(&l, g.origin + d * (g.width * f + 0.3)), 1); + assert_eq!(hit_byte(&l, g.origin + d * 0.3), 0); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + assert!(((q[0][1] - q[0][0]).hypot() - g.width * f).abs() < 1e-6, "{q:?}"); +} + +/// Bundled Source Serif 4's fi ligature has a caret stop that a click and the arrow keys reach. +#[test] +fn source_serif_fi_and_ffi_have_inner_stops() { + let db = FontDb::with_font_dirs(vec![]); + for (s, stops) in [("fi", vec![1]), ("ffi", vec![1, 2])] { + let l = layout(&db, &point(s, "Source Serif 4")); + let lone: Vec<&PositionedGlyph> = l.glyphs.iter().filter(|g| g.len == s.len()).collect(); + if lone.is_empty() { + // The font has no ligature for these letters (ffi in some versions). + assert_ne!(s, "fi", "{:?}", l.glyphs); + continue; + } + let g = lone[0]; + assert_eq!(g.carets.iter().map(|c| c.0).collect::>(), stops, "{s}"); + for &(b, f) in &g.carets { + let x = g.origin.x + g.advance * f; + assert_eq!(hit_byte(&l, Point::new(x + 0.01, -10.0)), b, "{s} byte {b}"); + assert!((caret_x(&l, b) - x).abs() < 1e-6); + } + let mut b = 0; + let mut seen = vec![]; + while b < s.len() { + b = caret_horizontal(&l, b, true); + seen.push(b); + } + assert_eq!(seen[..stops.len()], stops[..], "{s}"); + } +} + +/// A right-to-left ligature (lam + alef, two bytes each) has its stop between the two characters, +/// never inside a character, and its first character is on its right. +#[test] +fn a_right_to_left_ligature_stops_between_its_characters() { + let db = db_with(lam_alef_font(b"rlig")); + let l = layout(&db, &point("\u{0644}\u{0627}", lam_alef_family(b"rlig"))); + let g = l.glyphs.iter().find(|g| g.len == 4).expect("lam + alef is one glyph"); + assert!(g.rtl); + assert_eq!(g.carets, vec![(2, 0.5)]); + // Right of the middle is the lam, left of it the alef. + assert_eq!(hit_byte(&l, along(g, 0.9)), 0); + assert_eq!(hit_byte(&l, along(g, 0.6)), 2); + assert_eq!(hit_byte(&l, along(g, 0.1)), 4); + assert!((caret_x(&l, 2) - (g.origin.x + g.advance * 0.5)).abs() < 1e-6); + assert!(caret_x(&l, 0) > caret_x(&l, 2) && caret_x(&l, 2) > caret_x(&l, 4)); + // The lam alone is selected on the right half. + let q = selection_quads(&l, 0, 2); + assert_eq!(q.len(), 1, "{q:?}"); + let (x0, x1) = (q[0][0].x.min(q[0][1].x), q[0][0].x.max(q[0][1].x)); + assert!((x0 - (g.origin.x + g.advance * 0.5)).abs() < 1e-6 && (x1 - (g.origin.x + g.advance)).abs() < 1e-6, "{q:?}"); + // Arrow keys (visual stops) take the inner stop: right to left, left goes forward. + assert_eq!(caret_horizontal_cluster(&l, "\u{0644}\u{0627}", 0, false), 2); + assert_eq!(caret_horizontal_cluster(&l, "\u{0644}\u{0627}", 2, false), 4); +} + +/// Right to left, manual kerning moves a ligature right and puts its space on the left: the inner +/// stop moves with the glyph, and the click, the caret and the selection of each character follow. +#[test] +fn manual_kerning_moves_a_right_to_left_ligature_stop_with_the_glyph() { + let db = db_with(lam_alef_font(b"rlig")); + let at = |kern: f64| { + let mut t = point("\u{0644}\u{0627}", lam_alef_family(b"rlig")); + t.runs[0].style.kerning = Some(kern); + let l = layout(&db, &t); + let g = l.glyphs.iter().find(|g| g.len == 4).expect("lam + alef is one glyph").clone(); + assert!(g.rtl); + (l, g) + }; + let (plain, p) = at(0.0); + let (kerned, k) = at(100.0); + // 100/1000 em at 100 pt. + assert!((k.width_start - 10.0).abs() < 1e-9 && (k.advance - p.advance - 10.0).abs() < 1e-9, "{} {} {}", k.width_start, k.advance, p.advance); + let ink = |g: &PositionedGlyph| g.outline.bounding_box().x0; + assert!((ink(&k) - k.origin.x - (ink(&p) - p.origin.x) - 10.0).abs() < 1e-6, "the glyph moves right"); + let mid = k.origin.x + 10.0 + k.width * 0.5; + assert!((caret_x(&kerned, 2) - ink(&k) - (caret_x(&plain, 2) - ink(&p))).abs() < 1e-6, "the stop moves with the glyph"); + assert!((caret_x(&kerned, 2) - mid).abs() < 1e-6, "{} against {mid}", caret_x(&kerned, 2)); + assert_eq!(hit_byte(&kerned, Point::new(mid + 1.0, k.origin.y)), 2); + assert_eq!(hit_byte(&kerned, Point::new(mid + 4.0, k.origin.y)), 2); + assert_eq!(hit_byte(&kerned, Point::new(k.origin.x + k.advance - 1.0, k.origin.y)), 0); + let q = selection_quads(&kerned, 0, 2); + assert_eq!(q.len(), 1, "{q:?}"); + let x0 = q[0][0].x.min(q[0][1].x); + assert!((x0 - mid).abs() < 1e-6, "the lam alone is selected right of the stop: {q:?}"); +} + +/// A letter with a combining mark is one character: a glyph for it has no inner stop. +#[test] +fn a_letter_and_its_mark_has_no_inner_stop() { + let db = FontDb::with_font_dirs(vec![]); + let l = layout(&db, &point("e\u{0301}", "Source Sans 3")); + assert!(l.glyphs.iter().all(|g| g.carets.is_empty()), "{:?}", l.glyphs); +} + +/// On a path, a click on either half of the ligature goes to its nearer stop, and the selection of +/// one character covers its part of the glyph. +#[test] +fn a_ligature_on_a_path_has_its_inner_stop() { + let db = db_with(ligature_caret_font(None)); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + let mut t = point("fi", LIGATURE_CARET_FAMILY); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(&db, &t); + assert!(l.on_path); + let g = l.glyphs.iter().find(|g| g.len == 2).expect("f + i is one glyph"); + assert_eq!(g.carets, vec![(1, 0.5)]); + let at = |f: f64| g.origin + Vec2::new(g.angle.cos(), g.angle.sin()) * (g.advance * f); + assert_eq!(hit_byte(&l, at(0.1)), 0); + assert_eq!(hit_byte(&l, at(0.4)), 1); + assert_eq!(hit_byte(&l, at(0.95)), 2); + let q = selection_quads(&l, 1, 2); + assert_eq!(q.len(), 1); + assert!(((q[0][1] - q[0][0]).hypot() - g.advance * 0.5).abs() < 1e-6, "{q:?}"); +} + +/// In vertical type, the inner stop of a turned Latin ligature lies down the column between its +/// ends. +#[test] +fn a_ligature_in_vertical_type_has_its_inner_stop() { + let db = db_with(ligature_caret_font(None)); + let mut t = point("fi", LIGATURE_CARET_FAMILY); + t.vertical = true; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + assert_eq!(g.carets, vec![(1, 0.5)]); + let y = |b| caret_position(&l, b).0.y; + assert!(y(0) < y(1) && y(1) < y(2), "{} {} {}", y(0), y(1), y(2)); + let mid = caret_position(&l, 1); + assert_eq!(hit_byte(&l, mid.0.midpoint(mid.1) + Vec2::new(0.0, 0.01)), 1); +} + +/// In vertical type, tracking adds space after a turned ligature's own width as it does across: +/// the font's caret stays the same distance down the column from the ligature's start, and the +/// last character's selection runs from it to the end of the advance. +#[test] +fn tracking_does_not_move_a_ligature_caret_in_vertical_type() { + let db = db_with(ligature_caret_font(Some(150))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let k = 100.0 / face.units_per_em(); + let at = |tracking: f64| { + let mut t = point("fi", LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.tracking = tracking; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let width = face.advance(g.gid) * k; + assert!((g.width - width).abs() < 1e-9, "{} against {width}", g.width); + let y = |b| caret_position(&l, b).0.y; + let q = selection_quads(&l, 1, 2); + assert_eq!(q.len(), 1, "{q:?}"); + let ys = q[0].map(|p| p.y); + let (y0, y1) = (ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)); + assert!((y0 - y(1)).abs() < 1e-6 && (y1 - y(2)).abs() < 1e-6, "{q:?} {} {}", y(1), y(2)); + (y(1) - y(0), y(2) - y(0), g.advance) + }; + let (plain, plain_end, _) = at(0.0); + let (tracked, tracked_end, advance) = at(50.0); + assert!((plain - 150.0 * k).abs() < 1e-6, "{plain} against {}", 150.0 * k); + assert!((tracked - plain).abs() < 1e-6, "{tracked} against {plain}"); + assert!((tracked_end - advance).abs() < 1e-6 && (tracked_end - plain_end - 5.0).abs() < 1e-6, "{tracked_end} {plain_end} {advance}"); +} + +/// A ligature standing upright in vertical type (Standard Vertical Roman Alignment) takes its font +/// caret as a y coordinate: the stop lies that far below the top of its cell, which runs from its +/// vertical origin (0.88 em) one em down the column. A caret above the cell is not used. +#[test] +fn an_upright_ligature_takes_its_caret_down_its_cell() { + let at = |caret: i16| { + let db = db_with(vertical_ligature_caret_font(caret)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let face = db.face(VERTICAL_LIGATURE_CARET_FAMILY, "Regular").expect("the test font is added"); + let cell = 1000.0 * 100.0 / face.units_per_em(); + assert!((g.width - cell).abs() < 1e-9, "the glyph stands upright, one em down the column: {} against {cell}", g.width); + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + let y = |b| caret_position(&l, b).0.y; + assert!(((y(1) - y(0)) - g.width * f).abs() < 1e-6, "{} {} against {f}", y(0), y(1)); + f + }; + // 0.88 em at the top: y = 280 is 600 units down, y = -50 is 930 units down. + assert!((at(280) - 0.6).abs() < 1e-9, "{}", at(280)); + assert!((at(-50) - 0.93).abs() < 1e-9, "{}", at(-50)); + assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); +} + +/// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its +/// caret down its cell, and its cell starts where it does without tracking, with or without the +/// font's vertical metrics. +#[test] +fn an_upright_ligature_with_a_negative_advance_keeps_its_cell() { + let at = |font: Option>, family: &str, tracking: f64| { + let db = db_with(font); + let mut t = point("fi", family); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + t.runs[0].style.tracking = tracking; + // Ligatures turned on by name stay on at any tracking. + t.runs[0].style.features = vec!["+liga".into()]; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let face = db.face(family, "Regular").expect("the test font is added"); + assert!(tracking == 0.0 || g.advance <= 0.0, "the tracking makes the advance negative: {}", g.advance); + let cell = 1000.0 * 100.0 / face.units_per_em(); + assert!((g.width - cell).abs() < 1e-9, "the glyph stands upright, one em down the column: {} against {cell}", g.width); + let y = |b| caret_position(&l, b).0.y; + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!(((y(1) - y(0)) - g.width * f).abs() < 1e-6, "{} {} against {f}", y(0), y(1)); + (f, g.outline.bounding_box().y0 - y(0)) + }; + let vertical = |tracking| at(vertical_ligature_caret_font(280), VERTICAL_LIGATURE_CARET_FAMILY, tracking); + let (f, top) = vertical(0.0); + assert!((f - 0.6).abs() < 1e-9, "{f}"); + let (tracked, tracked_top) = vertical(-1500.0); + assert!((tracked - f).abs() < 1e-9, "the caret is read down the cell: {tracked} against {f}"); + assert!((tracked_top - top).abs() < 1e-6, "{tracked_top} against {top}"); + let plain = |tracking| at(ligature_caret_font(None), LIGATURE_CARET_FAMILY, tracking); + let (f, top) = plain(0.0); + assert!((f - 0.5).abs() < 1e-9, "{f}"); + let (tracked, tracked_top) = plain(-1500.0); + assert!((tracked - f).abs() < 1e-9, "{tracked} against {f}"); + assert!((tracked_top - top).abs() < 1e-6, "the cell starts where it does without tracking: {tracked_top} against {top}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7d526a04a..bccb00768 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854). + Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position.