//! Text layout: shaping styled spans, fallback fonts, bidirectional text, line breaking, //! alignment and decorations. use std::sync::Arc; use figcraft_doc::{ DecorationLength, DecorationStyle, LeadingTrim, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag, }; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; use crate::fontdb::{Coords, Face, db, no_coords}; /// A positioned glyph (pen position on the baseline, layout coordinates, y down). #[derive(Clone, Debug)] pub struct Glyph { pub face: Arc, /// Variable font coordinates (empty = the face's default instance). pub coords: Coords, pub gid: u32, pub x: f64, pub y: f64, pub size: f64, pub advance: f64, /// Source char range of the glyph's cluster. pub char_start: usize, pub char_end: usize, /// Index into [`TextLayout::styles`]. pub style: usize, /// Part of a right-to-left run: its first char sits at the glyph's right edge. pub rtl: bool, /// Moves the placed glyph onto the text's path (identity without one). pub warp: Affine, } impl Glyph { /// The glyph outline placed in layout coordinates. pub fn path(&self) -> BezPath { let s = self.size / self.face.upem; let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone(); p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y])); p } } #[derive(Clone, Debug)] pub struct Line { /// Glyph index range. pub glyph_start: usize, pub glyph_end: usize, pub char_start: usize, pub char_end: usize, pub top: f64, pub height: f64, pub baseline: f64, pub width: f64, pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } /// A decoration line (underline/strikethrough) to paint with the style's fills. #[derive(Clone, Debug)] pub struct Decoration { /// Everything the line paints (a wavy line's band includes its swing). pub rect: Rect, pub style: usize, pub kind: DecorationStyle, pub thickness: f64, } /// Most dots or wave samples drawn for one decoration. const MAX_DECO_STEPS: usize = 20_000; impl Decoration { /// The line's outline: a bar, a row of dots or a wave, `thickness` thick. pub fn path(&self) -> BezPath { use figcraft_geom::kurbo::Shape; let (r, th) = (self.rect, self.thickness.max(0.01)); let cy = r.center().y; match self.kind { DecorationStyle::Solid => r.to_path(0.1), DecorationStyle::Dotted => { let mut p = BezPath::new(); let mut x = r.x0 + th / 2.0; for _ in 0..MAX_DECO_STEPS { if x > r.x1 - th / 2.0 + 1e-9 && !p.elements().is_empty() { break; } p.extend(figcraft_geom::kurbo::Circle::new((x, cy), th / 2.0).to_path(0.05)); x += th * 2.0; } p } DecorationStyle::Wavy => { let (amp, period) = wave(th); let n = (((r.width() / period) * 8.0).ceil() as usize).clamp(2, MAX_DECO_STEPS); let at = |i: usize, side: f64| { let x = r.x0 + r.width() * i as f64 / n as f64; figcraft_geom::Point::new(x, cy + amp * (std::f64::consts::TAU * (x - r.x0) / period).sin() + side * th / 2.0) }; let mut p = BezPath::new(); p.move_to(at(0, -1.0)); for i in 1..=n { p.line_to(at(i, -1.0)); } for i in (0..=n).rev() { p.line_to(at(i, 1.0)); } p.close_path(); p } } } } /// A wavy line's swing either side of its centre and its wavelength, for a thickness. fn wave(th: f64) -> (f64, f64) { let amp = th * 0.75 + 0.5; (amp, amp * 6.0) } /// A decoration length in px (`auto`: what the font says). fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { let v = match m { DecorationLength::Auto => auto, DecorationLength::Pixels(v) => v, DecorationLength::Percent(p) => size * p / 100.0, }; if v.is_finite() { v } else { auto } } /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') } /// Closing punctuation and stops, which hang outside a line's end. fn closes(c: char) -> bool { matches!( c, '.' | ',' | ':' | ';' | '!' | '?' | '"' | '\'' | '\u{201D}' | '\u{2019}' | '\u{00BB}' | '\u{203A}' | ')' | ']' | '}' | '-' | '\u{2013}' | '\u{2014}' | '\u{2026}' ) } #[derive(Clone, Debug, Default)] pub struct TextLayout { pub glyphs: Vec, pub lines: Vec, pub decorations: Vec, /// The distinct styles the glyphs refer to. pub styles: Vec, /// Size of the laid-out content. pub width: f64, pub height: f64, /// Whether text was cut off (truncation). pub truncated: bool, /// Where the text sits on its path (text on a path). pub on_path: Option, } /// Ratio of the "Auto" line height to the font size (font ascent+descent+gap, per face). fn natural_ratio(face: &Face) -> f64 { ((face.ascent + face.descent + face.line_gap.max(0.0)) / face.upem).max(0.5) } /// A line's height: whole pixels (halves round up), whatever unit it was given in. Heights under a /// pixel stay as they are, so tiny text keeps its lines apart. fn whole_px(line_height: f64) -> f64 { if line_height >= 1.0 { (line_height + 0.5).floor() } else { line_height } } /// Advances sit on a grid of this many steps per em, then scale with the font size. const ADVANCE_GRID: f64 = 1024.0; /// `units` (font units) on the advance grid, in ems. fn on_grid(units: f64, upem: f64) -> f64 { (units * ADVANCE_GRID / upem).round() / ADVANCE_GRID } struct Item { face: Arc, coords: Coords, /// Glyph scale relative to the style's size (synthesised small caps). scale: f64, gid: u32, adv: f64, /// The letter spacing included in `adv` (none after a line's last visible glyph). spacing: f64, dx: f64, dy: f64, char_start: usize, char_end: usize, style: usize, ch: char, /// Bidi embedding level (odd = right to left). level: u8, } impl Item { fn is_space(&self) -> bool { crate::breaks::is_space(self.ch) } } /// Bidi embedding levels per char, and whether each char's paragraph runs right to left. pub(crate) struct Bidi { pub(crate) levels: Vec, pub(crate) rtl_para: Vec, } impl Bidi { /// None when the text has nothing right to left (the common case costs one scan). pub(crate) fn of(text: &str) -> Option { if !text.chars().any(is_rtl_char) { return None; } let info = unicode_bidi::BidiInfo::new(text, None); let mut paras = info.paragraphs.iter().peekable(); let n = text.chars().count(); let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n)); for (b, _) in text.char_indices() { while paras.peek().is_some_and(|p| p.range.end <= b) { paras.next(); } levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0)); rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl())); } Some(Bidi { levels, rtl_para }) } } /// Characters of right-to-left scripts and the explicit right-to-left marks. pub(crate) fn is_rtl_char(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067) } /// Visual order of a line's items from their levels (rule L2: from the highest level down to the /// lowest odd one, reverse every run at that level or above). pub(crate) fn visual_order(levels: &[u8]) -> Vec { let mut order: Vec = (0..levels.len()).collect(); let Some(lowest_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else { return order }; let highest = levels.iter().copied().max().unwrap_or(0); let level = |order: &[usize], i: usize| order.get(i).and_then(|k| levels.get(*k)).copied().unwrap_or(0); for lvl in (lowest_odd..=highest).rev() { let mut i = 0; while i < order.len() { if level(&order, i) < lvl { i += 1; continue; } let start = i; while i < order.len() && level(&order, i) >= lvl { i += 1; } if let Some(run) = order.get_mut(start..i) { run.reverse(); } } } order } /// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width /// pass `None`). `box_height` is used for truncation only. pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { let chars: Vec = text.characters.chars().collect(); let spans = text.spans(); let mut styles: Vec = Vec::new(); let mut span_style: Vec = Vec::with_capacity(spans.len()); for (_, _, s) in &spans { let idx = match styles.iter().position(|x| x == *s) { Some(i) => i, None => { styles.push((*s).clone()); styles.len() - 1 } }; span_style.push(idx); } let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); let fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } drop(dbw); // OpenType features the user toggled (global for the node). let features: Vec = text .features .iter() .filter(|(k, _)| valid_feature_tag(k)) .filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..))) .collect(); // Shape each span, split by paragraph, bidi run and fallback face. let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec = Vec::with_capacity(chars.len()); for (si, (start, end, _)) in spans.iter().enumerate() { let st_idx = span_style.get(si).copied().unwrap_or(0); let Some(style) = styles.get(st_idx) else { continue }; let Some(Some(primary)) = faces.get(st_idx) else { continue }; let size = sanitize_size(style.font_size); let spacing = style.letter_spacing.resolve(size); // Segment by face. let mut seg_start = *start; while seg_start < *end { let face_for = |c: char| -> Arc { if c == '\n' || primary.has_char(c) { return primary.clone(); } fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' '); let face = face_for(first); let level = level_at(seg_start); let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } if first == '\n' { items.push(Item { face: face.clone(), coords: no_coords(), scale: 1.0, gid: 0, adv: 0.0, spacing: 0.0, dx: 0.0, dy: 0.0, char_start: seg_start, char_end: seg_start + 1, style: st_idx, ch: '\n', level, }); } else { let seg = Segment { start: seg_start, end: seg_end, style, size, spacing, st_idx, level }; shape_segment(&chars, &seg, &face, &features, &mut items); } seg_start = seg_end; } } // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the // indent are on the right, the text ends where they start. let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); let markers = list_markers(&lists); let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); let hang_list = if text.hanging_list { step } else { 0.0 }; // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; let indent_at = |char_idx: usize| { let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; if start_aligned { indent_px } else { 0.0 } }; let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). let line_indent_of = |p: usize, first: bool, ci: usize| { let pl = list_of(p); match pl.list { ListStyle::None if first => indent_at(ci), ListStyle::None => 0.0, _ => step * (f64::from(pl.indent) + 1.0) - hang_list, } }; // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge // of right-aligned and justified ones (left-to-right paragraphs). let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); // Line breaking. let wrap = match text.auto_resize { TextAutoResize::WidthAndHeight => None, _ => box_width.filter(|w| w.is_finite() && *w > 0.0), }; let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0; let mut x = 0.0; let mut last_break: Option = None; let mut para_first_line = true; let mut para = 0; while i < items.len() { let Some(it) = items.get(i) else { break }; if it.ch == '\n' { lines.push((line_start, i + 1, true, para)); i += 1; line_start = i; x = 0.0; last_break = None; para_first_line = true; para += 1; continue; } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. let ltr = !rtl_para_at(it.char_start); let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv) + if hang_end && ltr && closes(it.ch) { w } else { 0.0 }; if let Some(maxw) = wrap && x + w - it.spacing > maxw - line_indent + hung + 1e-6 && i > line_start && !it.is_space() { let brk = last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i); lines.push((line_start, brk, false, para)); line_start = brk; i = brk; x = 0.0; last_break = None; para_first_line = false; continue; } x += w; if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) { last_break = Some(i); } i += 1; } if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { lines.push((line_start, items.len(), true, para)); } // Line metrics and positions. let default_style = styles.first().cloned().unwrap_or_default(); let default_face = faces.first().cloned().flatten(); let mut out = TextLayout { styles: styles.clone(), ..Default::default() }; let mut y = 0.0; let mut para_start_line = true; let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX }; // A style's vertical metrics (ascent, descent, line height, cap height) come from its own // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. let metrics = |st_idx: usize| -> [f64; 4] { let st = styles.get(st_idx).unwrap_or(&default_style); let size = sanitize_size(st.font_size); match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { Some(f) => { let k = size / f.upem; [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k] } None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], } }; let mut first_cap = 0.0; for (li, (s, e, para_end, para)) in lines.iter().enumerate() { if li >= max_lines { out.truncated = true; break; } // Metrics from the styles present on the line (or the style at the break for empty lines). let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; let mut seen = usize::MAX; for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { if st != seen { seen = st; let m = metrics(st); (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); } } if seen == usize::MAX { [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); } if li == 0 { first_cap = cap; } if para_start_line && li > 0 { // Between two items of a list: the list spacing. let in_list = |p: usize| list_of(p).list != ListStyle::None; let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; } let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); let rtl_line = rtl_para_at(first_char); let line_indent = line_indent_of(*para, para_start_line, first_char); // Hanging punctuation at the line's edges (the visible text's first and last chars). let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); let hang_r = items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), _ => 0.0, }; // Where the visible text ends once justified. let text_end = width + justify_extra * gaps as f64; // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang // outside it, so its visible text spans [0, text_end] with the gutter and indent after it. let trailing: f64 = items .get(vis_end..*e) .unwrap_or(&[]) .iter() .filter(|it| it.ch != '\n') .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; // List marker (before the line's text; zero-length char range so carets skip it). let pl = list_of(*para); if para_start_line && pl.list != ListStyle::None { let marker = markers.get(*para).cloned().unwrap_or_default(); let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); // The marker takes the paragraph style's own font (not a fallback the line's first // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a // nested bullet the font lacks comes from a fallback font. let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); let own = faces.get(st_idx).cloned().flatten().filter(has); let marker_face = match (own, styles.get(st_idx)) { (Some(f), Some(st)) => { let coords = f.coords(&f.axis_values(st)); Some((f, coords)) } _ => fallbacks .iter() .find(|f| has(f)) .map(|f| (f.clone(), no_coords())) .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) .or_else(|| default_face.clone().map(|f| (f, no_coords()))), }; if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { let size = sanitize_size(st.font_size); let k = size / face.upem; let numbered = pl.list == ListStyle::Numbered; // Right to left, the number reads "1." with its full stop on the left (".1"). let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; let advance = marker_advance(&face, &coords, &marker, size); // Numbers end (start, right to left) half an em from the text; bullets sit at the // gutter's outer edge. let gutter = step * f64::from(pl.indent) - hang_list; let mut mx = match (rtl_line, numbered) { (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), (false, false) => gutter, (true, true) => text_end + size * 0.5, (true, false) => text_end + (step - advance).max(0.0), }; for c in marker.chars() { let gid = face.glyph_for(c); let adv = face.advance_at(gid, &coords) * k; out.glyphs.push(Glyph { face: face.clone(), coords: coords.clone(), gid, x: mx, y: baseline, size, advance: adv, char_start: at, char_end: at, style: st_idx, rtl: false, warp: Affine::IDENTITY, }); mx += adv; } } } // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's // direction (rule L1). let line_items = items.get(*s..*e).unwrap_or(&[]); let order: Vec = if bidi.is_some() { let para_level = u8::from(rtl_line); let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); visual_order(&levels) } else { (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { if it.ch == '\n' { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; if it.gid != 0 || !it.is_space() { out.glyphs.push(Glyph { face: it.face.clone(), coords: it.coords.clone(), gid: it.gid, x: pen + it.dx, y: baseline - it.dy, size, advance: it.adv, char_start: it.char_start, char_end: it.char_end, style: it.style, rtl: it.level % 2 == 1, warp: Affine::IDENTITY, }); } pen += it.adv; if it.is_space() { pen += justify_extra; } } let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len()); let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start); out.lines.push(Line { glyph_start: gstart, glyph_end: out.glyphs.len(), char_start, char_end, top: y, height: lh, baseline, width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, rtl: rtl_line, }); y += lh; para_start_line = *para_end; } out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); if trim && let Some(first) = out.lines.first() { let shift = first.baseline - first_cap; for l in &mut out.lines { l.top -= shift; l.baseline -= shift; } for g in &mut out.glyphs { g.y -= shift; } } let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; out.height = out.lines.last().map_or(y, bottom).max(0.0); // Truncation by height. if matches!(text.auto_resize, TextAutoResize::Truncate) && let Some(h) = box_height && out.height > h + 0.5 { let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1); if keep < out.lines.len() { out.lines.truncate(keep); let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0); out.glyphs.truncate(gend); out.truncated = true; out.height = out.lines.last().map_or(0.0, bottom); } } if out.truncated { add_ellipsis(&mut out, wrap); } // Horizontal alignment within the box. let align_width = match text.auto_resize { TextAutoResize::WidthAndHeight => out.width, _ => box_width.unwrap_or(out.width), }; for li in 0..out.lines.len() { let Some(line) = out.lines.get(li) else { continue }; let dx = match text.align_h { TextAlignH::Left | TextAlignH::Justified => 0.0, TextAlignH::Center => (align_width - line.width) / 2.0, TextAlignH::Right => align_width - line.width, }; if dx != 0.0 { let (gs, ge) = (line.glyph_start, line.glyph_end); for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) { g.x += dx; } if let Some(l) = out.lines.get_mut(li) { l.x = dx; } } } // Decorations per contiguous glyph run of the same style on a line; a decoration colour // paints with a style of its own, added after the glyphs' styles. let mut decos = Vec::new(); let mut deco_styles: Vec = Vec::new(); for line in &out.lines { let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); let mut j = 0; while j < gl.len() { let Some(g0) = gl.get(j) else { break }; // List markers and the ellipsis are not decorated. if g0.char_end == g0.char_start { j += 1; continue; } let st = out.styles.get(g0.style).cloned().unwrap_or_default(); let mut k = j + 1; while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) { k += 1; } if st.decoration != TextDecoration::None && let Some(last) = gl.get(k - 1) { let x0 = g0.x; let x1 = last.x + last.advance; // The style's own font and size (not a fallback face or a small-cap scale), so the // line runs at one height and weight through mixed scripts. let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face); let size = sanitize_size(st.font_size); let s = size / f.upem; let look = st.look(); let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size); let underline = st.decoration == TextDecoration::Underline; // The line's top edge (an underline's own offset is measured from the baseline). let top = match look.offset { DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0, m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size), _ => line.baseline - f.strike_pos * s - th / 2.0, }; let style = match look.color { Some(c) => { let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() }; let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| { deco_styles.push(own); deco_styles.len() - 1 }); out.styles.len() + k } None => g0.style, }; let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 }; // Skip ink: the underline stops a little before each glyph crossing it. let gap = th.max(1.0); let cuts = if underline && look.skip_ink { ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap) } else { Vec::new() }; let mut from = x0; for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) { if c0.min(x1) > from + 0.5 { let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing); decos.push(Decoration { rect, style, kind: look.style, thickness: th }); } from = from.max(c1); } } j = k; } } out.decorations = decos; out.styles.extend(deco_styles); if let Some(tp) = &text.path { crate::onpath::place(&mut out, tp, text.align_h); } out } /// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at /// one level, restarting after a plain paragraph, a shallower item or the other kind of list; /// levels go 1. a. i. and • ◦ ▪, then round again. pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"]; let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1]; lists .iter() .map(|pl| { if pl.list == ListStyle::None { count.fill((ListStyle::None, 0)); return String::new(); } let d = usize::from(pl.indent).min(count.len() - 1); for c in count.iter_mut().skip(d + 1) { *c = (ListStyle::None, 0); } let Some(c) = count.get_mut(d) else { return String::new() }; *c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) }; match (pl.list, d % 3) { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), (ListStyle::Numbered, 2) => format!("{}.", roman(c.1)), (ListStyle::Numbered, _) => format!("{}.", c.1), _ => BULLETS.get(d % 3).copied().unwrap_or("\u{2022}").to_string(), } }) .collect() } /// a, b, ... z, aa, ab, ... fn alpha(mut n: usize) -> String { let mut out = Vec::new(); while n > 0 { n -= 1; out.push(char::from(b'a' + (n % 26) as u8)); n /= 26; } out.iter().rev().collect() } /// Lower-case roman numerals (past 3999: the number). fn roman(mut n: usize) -> String { if n == 0 || n > 3999 { return n.to_string(); } let mut out = String::new(); for (v, r) in [ (1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"), ] { while n >= v { out.push_str(r); n -= v; } } out } /// The width of one list level's gutter: room for the widest number plus half an em, at least one /// and a half ems (0 without lists). fn list_step(lists: &[ParaList], markers: &[String], style: Option<&TextStyle>, face: Option<&Arc>) -> f64 { if lists.iter().all(|l| l.list == ListStyle::None) { return 0.0; } let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0); let widest = lists .iter() .zip(markers) .filter(|(l, _)| l.list == ListStyle::Numbered) .map(|(_, m)| face.map(|f| marker_advance(f, &no_coords(), m, size)).unwrap_or(size * 0.6 * m.chars().count() as f64)) .fold(0.0, f64::max); (widest + size * 0.5).max(size * 1.5) } fn marker_advance(face: &Face, coords: &Coords, s: &str, size: f64) -> f64 { let k = size / face.upem; s.chars().map(|c| face.advance_at(face.glyph_for(c), coords) * k).sum() } /// Where glyphs' outlines cross the band `y0..y1`: one x range per glyph, widened by `gap`. fn ink_cuts(glyphs: &[Glyph], y0: f64, y1: f64, gap: f64) -> Vec<(f64, f64)> { let mut cuts: Vec<(f64, f64)> = Vec::new(); for g in glyphs { let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); let mut prev: Option = None; figcraft_geom::kurbo::flatten(g.path().iter(), 0.25, |el| { use figcraft_geom::kurbo::PathEl; let p = match el { PathEl::MoveTo(p) => { prev = Some(p); return; } PathEl::LineTo(p) => p, _ => return, }; if let Some(a) = prev { // The part of segment a-p inside the band. let (t0, t1) = if (p.y - a.y).abs() < 1e-12 { if (y0..=y1).contains(&a.y) { (0.0, 1.0) } else { (1.0, 0.0) } } else { let (ta, tb) = ((y0 - a.y) / (p.y - a.y), (y1 - a.y) / (p.y - a.y)); (ta.min(tb).max(0.0), ta.max(tb).min(1.0)) }; if t0 <= t1 { for t in [t0, t1] { let x = a.x + (p.x - a.x) * t; (lo, hi) = (lo.min(x), hi.max(x)); } } } prev = Some(p); }); if lo <= hi { cuts.push((lo - gap, hi + gap)); } } cuts.sort_by(|a, b| a.0.total_cmp(&b.0)); cuts } fn add_ellipsis(out: &mut TextLayout, wrap: Option) { let Some(line) = out.lines.last().cloned() else { return }; let Some(last) = out.glyphs.get(line.glyph_end.saturating_sub(1)).cloned() else { return }; let face = last.face.clone(); let gid = face.glyph_for('…'); let gid = if gid == 0 { face.glyph_for('.') } else { gid }; let k = last.size / face.upem; let adv = face.advance_at(gid, &last.coords) * k; let limit = wrap.unwrap_or(f64::INFINITY); // Drop glyphs until the ellipsis fits, then any trailing spaces ("word…", not "word …"). while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.x + g.advance + adv > limit) { out.glyphs.pop(); } while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.char_end > g.char_start && g.gid == g.face.glyph_for(' ')) { out.glyphs.pop(); } let x = out.glyphs.last().filter(|_| out.glyphs.len() > line.glyph_start).map(|g| g.x + g.advance).unwrap_or(0.0); out.glyphs.push(Glyph { face, coords: last.coords.clone(), gid, x, y: line.baseline, size: last.size, advance: adv, char_start: last.char_end, char_end: last.char_end, style: last.style, rtl: false, warp: Affine::IDENTITY, }); if let Some(l) = out.lines.last_mut() { l.glyph_end = out.glyphs.len(); l.width = x + adv; } } fn sanitize_size(s: f64) -> f64 { if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 } } /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, end: usize, style: &'a TextStyle, size: f64, spacing: f64, st_idx: usize, /// Bidi level (odd: shaped right to left). level: u8, } /// Synthesised small caps: lower-case letters drawn as capitals at this fraction of the size. const SMALL_CAP_SCALE: f64 = 0.72; fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Feature], items: &mut Vec) { let (start, end, style) = (seg.start, seg.end, seg.style); let k = seg.size / face.upem; let small_caps = style.case == TextCase::SmallCaps; let native_smcp = small_caps && face.has_feature(b"smcp"); // Map each source char through the case (title case looks at the char before the segment, so // a word split across styled runs is capitalised once) and remember synthesised small caps. let mut mapped: Vec<(char, usize, bool)> = Vec::with_capacity(end.saturating_sub(start)); let mut prev = start.checked_sub(1).and_then(|i| chars.get(i)).copied(); for (i, c) in chars.get(start..end).unwrap_or(&[]).iter().enumerate() { let src = start + i; if small_caps && !native_smcp && c.is_lowercase() { for u in c.to_uppercase() { mapped.push((u, src, true)); } } else { let mut buf = [0u8; 4]; for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { mapped.push((u, src, false)); } } prev = Some(*c); } let mut small = vec![false; end.saturating_sub(start)]; for (_, src, sm) in &mapped { if *sm && let Some(f) = small.get_mut(src - start) { *f = true; } } let small_at = |src: usize| src.checked_sub(start).and_then(|i| small.get(i)).copied().unwrap_or(false); let mut feats: Vec = features.to_vec(); if native_smcp { feats.push(Feature::new(Tag::new(b"smcp"), 1, ..)); } // Variable fonts: the axis values the style asks for (named instance, implied weight, custom). let values = face.axis_values(style); let coords = face.coords(&values); let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new(); if let Some(hb) = face.hb() { let instance = (!coords.is_empty()).then(|| { let vars = face .axes .iter() .zip(&values) .filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 })); ShaperInstance::from_variations(&hb, vars) }); let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build(); let mut buf = UnicodeBuffer::new(); for (u, src, _) in &mapped { buf.add(*u, *src as u32); } let rtl = seg.level % 2 == 1; buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); buf.guess_segment_properties(); let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); } // Right-to-left glyphs come out in visual order; items are kept in logical order (lines // put runs back in visual order). if rtl { raw.reverse(); } } else { for (u, src, _) in &mapped { let gid = face.glyph_for(*u); raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0)); } } let n = raw.len(); for gi in 0..n { let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue }; let cl = cl as usize; // Cluster end: the next larger cluster value, else the segment end. let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1); let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 }; let spacing = if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 }; // The glyph's own advance and what shaping added to it (kerning) each land on the grid. let own = face.advance_at(gid, &coords); let adv = (on_grid(own, face.upem) + on_grid(f64::from(xa) - own, face.upem)) * seg.size * scale + spacing; items.push(Item { face: face.clone(), coords: coords.clone(), scale, gid, adv, spacing, dx: f64::from(xo) * k * scale, dy: f64::from(yo) * k * scale, char_start: cl, char_end: cend, style: seg.st_idx, ch, level: seg.level, }); } } impl TextLayout { /// A box (layout coordinates) holding everything the text paints: each glyph's advance box, /// grown by half its size for ascenders, descenders and overhangs, plus decorations. None when /// nothing is laid out. pub fn ink_bounds(&self) -> Option { let lines = self.lines.iter().map(|l| (l, self.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]))); let glyphs = lines.flat_map(|(l, gs)| { gs.iter().map(move |g| { let pad = g.size.abs() * 0.5; let r = Rect::new(g.x.min(g.x + g.advance) - pad, l.top - pad, g.x.max(g.x + g.advance) + pad, l.top + l.height + pad); figcraft_geom::transform_rect_bbox(g.warp, r) }) }); glyphs.chain(self.decorations.iter().map(|d| d.rect)).filter(|r| r.x0.is_finite() && r.y1.is_finite()).reduce(|a, b| a.union(b)) } /// The caret position (x, line index) before char `index`. pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); // A caret at the end of a soft-wrapped line belongs to the next line. if !(in_line || index == line.char_end && line.para_end && !last_line) { continue; } if index == line.char_end && line.para_end && !last_line { // Before the newline: end of this line. let x = self.line_end_x(line); return (x, li); } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { continue; } if index >= g.char_start && index < g.char_end { let frac = (index - g.char_start) as f64 / (g.char_end - g.char_start) as f64; let frac = if g.rtl { 1.0 - frac } else { frac }; return (g.x + g.advance * frac, li); } } return (self.line_end_x(line), li); } let li = self.lines.len().saturating_sub(1); (self.lines.last().map(|l| self.line_end_x(l)).unwrap_or(0.0), li) } /// The caret one step left or right of `index` on screen, for lines with right-to-left runs /// (None on plain left-to-right lines: there a step is one char). At the line's edge it moves /// on logically, to the neighbouring line. pub fn visual_step(&self, index: usize, right: bool) -> Option { let (x, li) = self.caret(index); let line = self.lines.get(li)?; let gl = self.glyphs.get(line.glyph_start..line.glyph_end)?; if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } // The caret can't sit after a paragraph's newline on its own line. let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) .filter(|(_, cx)| if right { *cx > x + 0.01 } else { *cx < x - 0.01 }) .min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs()).then(a.0.cmp(&b.0))) .map(|(i, _)| i); // At the edge: on to the next line in reading order. Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) })) } /// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph. fn line_end_x(&self, line: &Line) -> f64 { let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start); if line.rtl { glyphs.map(|g| g.x).fold(f64::INFINITY, f64::min).min(line.x + line.width) } else { glyphs.map(|g| g.x + g.advance).fold(line.x, f64::max) } } /// Layout point `(x, y)` where it shows: moved onto the path for text on a path. pub fn warp_point(&self, x: f64, y: f64) -> figcraft_geom::Point { let p = figcraft_geom::Point::new(x, y); let Some(on) = &self.on_path else { return p }; let baseline = self.lines.iter().find(|l| y <= l.top + l.height).or(self.lines.last()).map_or(y, |l| l.baseline); on.frame(x, baseline).map_or(p, |f| f * p) } /// Text on a path: the char nearest to where `(x, y)` shows, by the glyph it falls nearest to. fn hit_on_path(&self, x: f64, y: f64) -> usize { let p = figcraft_geom::Point::new(x, y); let near = self.glyphs.iter().filter(|g| g.char_end > g.char_start && g.warp.determinant().abs() > 1e-12).map(|g| { let q = g.warp.inverse() * p; let mid = g.x + g.advance / 2.0; ((q - figcraft_geom::Point::new(mid, g.y - g.size / 3.0)).hypot(), if q.x < mid { g.char_start } else { g.char_end }) }); near.min_by(|a, b| a.0.total_cmp(&b.0)).map_or(0, |(_, i)| i) } /// The char index nearest to `(x, y)` (layout coordinates). pub fn hit(&self, x: f64, y: f64) -> usize { if self.on_path.is_some() { return self.hit_on_path(x, y); } let Some((li, line)) = self.lines.iter().enumerate().find(|(_, l)| y < l.top + l.height).or_else(|| self.lines.iter().enumerate().next_back()) else { return 0; }; let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end <= g.char_start { continue; } if x < g.x + g.advance / 2.0 { // A right-to-left glyph's left half is after it. return if g.rtl { g.char_end } else { g.char_start }; } } let last_line = li + 1 == self.lines.len(); let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } /// Selection rectangles for chars `start..end`. pub fn selection_rects(&self, start: usize, end: usize) -> Vec { let (a, b) = (start.min(end), start.max(end)); let mut out = Vec::new(); for (li, line) in self.lines.iter().enumerate() { if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) { continue; } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); if line.rtl || gl.iter().any(|g| g.rtl) { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } let (x0, _) = self.caret(a.max(line.char_start)); let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; out.push(Rect::new(x0, line.top, x1.max(x0 + 1.0), line.top + line.height)); } out } /// Selection rectangles on a line with right-to-left runs: the selected part of each glyph /// (they're in visual order), merged where they touch. fn mixed_selection(gl: &[Glyph], a: usize, b: usize, line: &Line) -> Vec { let mut spans: Vec<(f64, f64)> = Vec::new(); for g in gl.iter().filter(|g| g.char_end > g.char_start) { let (ga, gb) = (g.char_start.max(a), g.char_end.min(b)); if ga >= gb { continue; } let n = (g.char_end - g.char_start) as f64; let (f0, f1) = ((ga - g.char_start) as f64 / n, (gb - g.char_start) as f64 / n); let (x0, x1) = if g.rtl { (g.x + g.advance * (1.0 - f1), g.x + g.advance * (1.0 - f0)) } else { (g.x + g.advance * f0, g.x + g.advance * f1) }; match spans.last_mut() { Some(last) if (x0 - last.1).abs() < 0.5 => last.1 = last.1.max(x1), _ => spans.push((x0, x1)), } } spans.into_iter().map(|(x0, x1)| Rect::new(x0, line.top, x1.max(x0 + 1.0), line.top + line.height)).collect() } /// All glyph outlines as one path (for outline/flatten and boolean ops). pub fn to_path(&self) -> BezPath { let mut p = BezPath::new(); for g in &self.glyphs { p.extend(g.path()); } for d in &self.decorations { p.extend(d.path()); } p } }