//! Text layout for slide text bodies. //! //! [`layout`] resolves every run's formatting through the placeholder/style chain and lays each //! paragraph out in the order bidirectional text needs: //! 1. embedding levels for the whole logical paragraph (UAX #9, base level from `a:pPr/@rtl`); //! 2. itemization by level, script, face (per-cluster fallback through the `a:latin`/`a:ea`/`a:cs` //! slots and the theme's script fonts) and shaping style, each item shaped with the whole //! paragraph as context; paint-only attributes (colour, highlight, underline) never split a //! word, so Arabic joining and ligatures survive them; //! 3. line breaking in logical order (UAX #14 opportunities at cluster boundaries, greedy fill); //! 4. per line, rules L1/L2 reorder the characters visually (L4 mirroring is done by the shaper). //! //! It then places bullets and numbers from the paragraph's start edge (the right edge for RTL //! paragraphs), applies indents, spacing and alignment (kashida for `justLow`), anchors the block //! in the shape's text box, and shrinks text on overflow when the body asks for it. The result is //! positioned glyph runs in shape-local coordinates plus per-line caret geometry for editing: //! bidirectional carets with affinity, logical and visual movement, and selection as the union of //! visual boxes. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] mod bidi; pub mod datetime; use std::collections::HashMap; use std::sync::Arc; use deckcraft_color::Rgba; use deckcraft_fonts::script::{self, joins_following, joins_preceding}; use deckcraft_fonts::{FontDb, FontFace, FontSlot, ScriptTag, ShapeParams}; use deckcraft_geom::{Point, Rect}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, ParaProps, RunKind, RunProps, Spacing, Strike, TextBody, TextDir}; use deckcraft_model::{Fill, Shape}; use unicode_segmentation::UnicodeSegmentation; /// A run of glyphs in one face, size and colour. #[derive(Clone)] pub struct GlyphRun { pub face: Arc, /// Font size in points (after autofit scaling). pub size: f64, pub color: Rgba, /// Glyph id and pen position of its origin (baseline), in shape-local points. pub glyphs: Vec<(u32, f64, f64)>, /// Per glyph: the paragraph character it draws (index, char); empty for bullets. pub cells: Vec<(usize, char)>, /// Draw a heavier stroke because the family has no bold face. pub fake_bold: bool, /// Slant because the family has no italic face. pub fake_italic: bool, pub outline: Option<(Rgba, f64)>, /// Paragraph and character range this run covers (for hyperlinks, selection colours). pub para: usize, pub chars: (usize, usize), pub link: bool, /// Opacity of the fill on top of `color`'s own alpha (0 for no fill). pub alpha: f64, pub gradient: Option, /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). pub effects: Option, /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn /// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is /// laid out turned a quarter turn. pub upright: bool, } /// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the /// em box from 0.88 em above to 0.12 em below it). pub const UPRIGHT_CENTRE: f64 = 0.38; impl GlyphRun { /// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box, /// `advance` wide (`None` when the run is not upright). pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> { if !self.upright { return None; } let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0); Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE)) } } /// A text run's shadow and glow, colours resolved. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RunEffects { pub shadow: Option, /// Glow colour (with its alpha) and radius in points. pub glow: Option<(Rgba, f64)>, } /// An outer shadow: colour (with its alpha), blur radius and offset in points. #[derive(Clone, Copy, Debug, PartialEq)] pub struct TextShadow { pub color: Rgba, pub blur: f64, pub dx: f64, pub dy: f64, } impl std::fmt::Debug for GlyphRun { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GlyphRun").field("family", &self.face.family).field("size", &self.size).field("glyphs", &self.glyphs.len()).finish() } } /// An underline, strikethrough or highlight box. #[derive(Clone, Debug)] pub struct Deco { pub rect: Rect, pub color: Rgba, /// Highlights draw behind text. pub behind: bool, /// Paragraph the decoration belongs to (by-paragraph animation). pub para: usize, /// How the line fills `rect`. pub line: DecoLine, } /// How an underline fills its box. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum DecoLine { /// The whole box. #[default] Solid, /// Dashes or dots across the box's height, from its left end. Dashed(Dash), /// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`. Wave { stroke: f64, period: f64 }, } /// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured /// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three /// thicknesses long with gaps of two. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dash { Dot, Dash, Long, DotDash, DotDotDash, } impl Dash { /// On, off, on, off… lengths in thicknesses. fn pattern(self) -> &'static [f64] { match self { Dash::Dot => &[1.0, 1.0], Dash::Dash => &[3.0, 2.0], Dash::Long => &[6.0, 2.0], Dash::DotDash => &[3.0, 2.0, 1.0, 2.0], Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0], } } } impl Deco { /// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy /// line gives a thin box along its middle, for drawing that can't stroke a path. pub fn pieces(&self) -> Vec { let r = self.rect; match self.line { DecoLine::Solid => vec![r], DecoLine::Wave { stroke, .. } => { let c = (r.y0 + r.y1) / 2.0; vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)] } DecoLine::Dashed(d) => { let unit = r.height().max(0.1); let pat = d.pattern(); let mut out = Vec::new(); let mut x = r.x0; let mut k = 0usize; while x < r.x1 && out.len() < 10_000 { let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit; if k.is_multiple_of(2) { out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1)); } x += len; k += 1; } out } } } /// The zigzag of a wavy underline, left to right from the box's top, and its stroke width; /// None for other lines. pub fn wave(&self) -> Option<(Vec, f64)> { let DecoLine::Wave { stroke, period } = self.line else { return None }; let r = self.rect; let half = (period / 2.0).max(0.1); let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000); let pts = (0..=n) .map(|i| { let x = (r.x0 + i as f64 * half).min(r.x1); Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 }) }) .collect(); Some((pts, stroke)) } } /// An underline style (`a:rPr/@u`). #[derive(Clone, Copy, Debug, PartialEq)] enum Underline { Single, Double, Heavy, /// Dotted or dashed, heavy or not. Dashed(Dash, bool), /// Wavy, heavy or not. Wavy(bool), } impl Underline { fn from_xml(u: &str) -> Option { Some(match u { "none" => return None, // PowerPoint underlines the spaces of a "words" underline too. "words" => Underline::Single, // At text sizes PowerPoint's double wave reads as two straight lines. "dbl" | "wavyDbl" => Underline::Double, "heavy" => Underline::Heavy, "dotted" => Underline::Dashed(Dash::Dot, false), "dottedHeavy" => Underline::Dashed(Dash::Dot, true), "dash" => Underline::Dashed(Dash::Dash, false), "dashHeavy" => Underline::Dashed(Dash::Dash, true), "dashLong" => Underline::Dashed(Dash::Long, false), "dashLongHeavy" => Underline::Dashed(Dash::Long, true), "dotDash" => Underline::Dashed(Dash::DotDash, false), "dotDashHeavy" => Underline::Dashed(Dash::DotDash, true), "dotDotDash" => Underline::Dashed(Dash::DotDotDash, false), "dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true), "wavy" => Underline::Wavy(false), "wavyHeavy" => Underline::Wavy(true), _ => Underline::Single, }) } /// The boxes of this underline below the baseline (top, bottom, fill), from the font's /// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export /// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two /// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every /// 3.9t. fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> { let heavy = (p - t, p + 0.55 * t); match self { Underline::Single => vec![(p, p + t, DecoLine::Solid)], Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)], Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)], Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))], Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))], Underline::Wavy(h) => { let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 }; vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })] } } } } #[derive(Clone, Debug, Default)] pub struct LineInfo { pub para: usize, /// Character range [start, end) in the paragraph (end excludes the line's break/newline). pub start: usize, pub end: usize, pub top: f64, pub baseline: f64, pub bottom: f64, /// Caret x for each character boundary start..=end (len = end - start + 1), downstream: the /// leading edge of the character after the boundary (its right edge if it is right-to-left); /// the last entry is the trailing edge of the line's last character. pub caret_x: Vec, /// Column index (multi-column bodies). pub column: usize, /// Right-to-left paragraph. pub rtl: bool, /// Bidi level of each character start..end after rule L1 (odd = right-to-left). pub levels: Vec, /// Visual box (left, right) of each character start..end. Characters inside a grapheme have /// empty boxes; a ligature's characters share its width (GDEF ligature carets). pub edges: Vec<(f64, f64)>, /// Whether a caret may stand before each character start..end (grapheme boundaries). pub boundary: Vec, /// Visual extent (left, right) of the line's text. pub extent: (f64, f64), /// Distance from the paragraph's start edge to the line's text (indent, margin, bullet). pub start_offset: f64, } #[derive(Clone, Debug, Default)] pub struct TextLayout { pub runs: Vec, /// Empty list markers for the editor only; ordinary rendering and export omit them. /// Their space is still reserved so the caret starts where the first character will. pub empty_bullets: Vec, pub decos: Vec, pub lines: Vec, /// Inner text box (insets applied), shape-local. pub inner: Rect, /// Height of the laid-out text (before anchoring). pub content_height: f64, /// Widest line. pub content_width: f64, /// Autofit font scale used (1 = none). pub font_scale: f64, pub line_reduction: f64, pub overflow: bool, /// Text rotation about the shape centre, degrees (vertical text = 90/270). pub rotation: f64, /// Per paragraph: (first line index, line count). pub para_lines: Vec<(usize, usize)>, /// Per paragraph: whether a caret may stand at each character offset 0..=len (grapheme /// boundaries), for logical caret movement. pub boundaries: Vec>, } /// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text. pub trait Fields { fn field(&self, kind: &str) -> Option; /// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are /// written the language's way. fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { let _ = lang; self.field(kind) } } pub struct NoFields; impl Fields for NoFields { fn field(&self, kind: &str) -> Option { if kind == "slidenum" { Some("1".into()) } else { None } } } pub struct Opts<'a> { /// The text box: the preset's text rectangle in shape-local space. pub rect: Rect, pub fields: &'a dyn Fields, /// Draw this text instead of the body (placeholder prompts), in this colour. pub prompt_color: Option, /// Ignore autofit shrinking (used to measure natural height). pub no_shrink: bool, } struct Style { face: Arc, size: f64, color: Rgba, alpha: f64, fake_bold: bool, fake_italic: bool, baseline: f64, spacing: f64, caps: Caps, underline: Option<(Rgba, Underline)>, strike: Option, highlight: Option, outline: Option<(Rgba, f64)>, link: bool, gradient: Option, effects: Option, /// "Regular", "Bold"… for the slot and fallback faces. style_name: &'static str, /// BCP 47 language (`a:rPr/@lang`), for shaping. lang: Option, /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one. ea: Option, cs: Option, /// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font /// characters) are drawn in it. sym: Option, /// Heading font (`+mj-…`): the theme's major font set applies. major: bool, /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. kern: bool, } /// One character of a paragraph after shaping. #[derive(Clone)] struct Cell { ch: char, style: usize, face: usize, /// Resolved bidi embedding level (before the line rules). level: u8, /// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid, /// x, y). Empty for the other cells of a cluster. glyphs: Vec<(u32, f64, f64)>, /// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme. adv: f64, /// Index of the cell heading this character's shaping cluster. head: usize, /// A grapheme starts here: carets stand and lines break only before such cells. boundary: bool, /// Drawn upright in East Asian vertical text. upright: bool, } impl Cell { /// A line may start before this cell: a grapheme that starts a shaping cluster. fn breakable(&self, i: usize) -> bool { self.boundary && self.head == i } } struct ParaShaped { cells: Vec, styles: Vec