Skip to main content

deckcraft_text/
lib.rs

1//! Text layout for slide text bodies.
2//!
3//! [`layout`] resolves every run's formatting through the placeholder/style chain and lays each
4//! paragraph out in the order bidirectional text needs:
5//! 1. embedding levels for the whole logical paragraph (UAX #9, base level from `a:pPr/@rtl`);
6//! 2. itemization by level, script, face (per-cluster fallback through the `a:latin`/`a:ea`/`a:cs`
7//!    slots and the theme's script fonts) and shaping style, each item shaped with the whole
8//!    paragraph as context; paint-only attributes (colour, highlight, underline) never split a
9//!    word, so Arabic joining and ligatures survive them;
10//! 3. line breaking in logical order (UAX #14 opportunities at cluster boundaries, greedy fill);
11//! 4. per line, rules L1/L2 reorder the characters visually (L4 mirroring is done by the shaper).
12//!
13//! It then places bullets and numbers from the paragraph's start edge (the right edge for RTL
14//! paragraphs), applies indents, spacing and alignment (kashida for `justLow`), anchors the block
15//! in the shape's text box, and shrinks text on overflow when the body asks for it. The result is
16//! positioned glyph runs in shape-local coordinates plus per-line caret geometry for editing:
17//! bidirectional carets with affinity, logical and visual movement, and selection as the union of
18//! visual boxes.
19#![deny(
20    clippy::unwrap_used,
21    clippy::expect_used,
22    clippy::panic,
23    clippy::unimplemented,
24    clippy::todo,
25    clippy::unreachable
26)]
27#![forbid(unsafe_code)]
28
29mod bidi;
30pub mod datetime;
31
32use std::collections::HashMap;
33use std::sync::Arc;
34
35use deckcraft_color::Rgba;
36use deckcraft_fonts::script::{self, joins_following, joins_preceding};
37use deckcraft_fonts::{FontDb, FontFace, FontSlot, ScriptTag, ShapeParams};
38use deckcraft_geom::{Point, Rect};
39use deckcraft_model::resolve::{self, Ctx};
40use deckcraft_model::text::{
41    Align, Anchor, AutoFit, BodyProps, Bullet, Caps, ParaProps, RunKind, RunProps, Spacing, Strike,
42    TextBody, TextDir,
43};
44use deckcraft_model::{Fill, Shape};
45use unicode_segmentation::UnicodeSegmentation;
46
47/// A run of glyphs in one face, size and colour.
48#[derive(Clone)]
49pub struct GlyphRun {
50    pub face: Arc<FontFace>,
51    /// Font size in points (after autofit scaling).
52    pub size: f64,
53    pub color: Rgba,
54    /// Glyph id and pen position of its origin (baseline), in shape-local points.
55    pub glyphs: Vec<(u32, f64, f64)>,
56    /// Per glyph: the paragraph character it draws (index, char); empty for bullets.
57    pub cells: Vec<(usize, char)>,
58    /// Draw a heavier stroke because the family has no bold face.
59    pub fake_bold: bool,
60    /// Slant because the family has no italic face.
61    pub fake_italic: bool,
62    pub outline: Option<(Rgba, f64)>,
63    /// Paragraph and character range this run covers (for hyperlinks, selection colours).
64    pub para: usize,
65    pub chars: (usize, usize),
66    pub link: bool,
67    /// Alpha of the fill (0–1).
68    pub alpha: f64,
69    pub gradient: Option<deckcraft_model::style::Gradient>,
70    /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
71    pub effects: Option<RunEffects>,
72}
73
74/// A text run's shadow and glow, colours resolved.
75#[derive(Clone, Copy, Debug, PartialEq)]
76pub struct RunEffects {
77    pub shadow: Option<TextShadow>,
78    /// Glow colour (with its alpha) and radius in points.
79    pub glow: Option<(Rgba, f64)>,
80}
81
82/// An outer shadow: colour (with its alpha), blur radius and offset in points.
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct TextShadow {
85    pub color: Rgba,
86    pub blur: f64,
87    pub dx: f64,
88    pub dy: f64,
89}
90
91impl std::fmt::Debug for GlyphRun {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("GlyphRun")
94            .field("family", &self.face.family)
95            .field("size", &self.size)
96            .field("glyphs", &self.glyphs.len())
97            .finish()
98    }
99}
100
101/// An underline, strikethrough or highlight box.
102#[derive(Clone, Debug)]
103pub struct Deco {
104    pub rect: Rect,
105    pub color: Rgba,
106    /// Highlights draw behind text.
107    pub behind: bool,
108    /// Paragraph the decoration belongs to (by-paragraph animation).
109    pub para: usize,
110}
111
112#[derive(Clone, Debug, Default)]
113pub struct LineInfo {
114    pub para: usize,
115    /// Character range [start, end) in the paragraph (end excludes the line's break/newline).
116    pub start: usize,
117    pub end: usize,
118    pub top: f64,
119    pub baseline: f64,
120    pub bottom: f64,
121    /// Caret x for each character boundary start..=end (len = end - start + 1), downstream: the
122    /// leading edge of the character after the boundary (its right edge if it is right-to-left);
123    /// the last entry is the trailing edge of the line's last character.
124    pub caret_x: Vec<f64>,
125    /// Column index (multi-column bodies).
126    pub column: usize,
127    /// Right-to-left paragraph.
128    pub rtl: bool,
129    /// Bidi level of each character start..end after rule L1 (odd = right-to-left).
130    pub levels: Vec<u8>,
131    /// Visual box (left, right) of each character start..end. Characters inside a grapheme have
132    /// empty boxes; a ligature's characters share its width (GDEF ligature carets).
133    pub edges: Vec<(f64, f64)>,
134    /// Whether a caret may stand before each character start..end (grapheme boundaries).
135    pub boundary: Vec<bool>,
136    /// Visual extent (left, right) of the line's text.
137    pub extent: (f64, f64),
138    /// Distance from the paragraph's start edge to the line's text (indent, margin, bullet).
139    pub start_offset: f64,
140}
141
142#[derive(Clone, Debug, Default)]
143pub struct TextLayout {
144    pub runs: Vec<GlyphRun>,
145    pub decos: Vec<Deco>,
146    pub lines: Vec<LineInfo>,
147    /// Inner text box (insets applied), shape-local.
148    pub inner: Rect,
149    /// Height of the laid-out text (before anchoring).
150    pub content_height: f64,
151    /// Widest line.
152    pub content_width: f64,
153    /// Autofit font scale used (1 = none).
154    pub font_scale: f64,
155    pub line_reduction: f64,
156    pub overflow: bool,
157    /// Text rotation about the shape centre, degrees (vertical text = 90/270).
158    pub rotation: f64,
159    /// Per paragraph: (first line index, line count).
160    pub para_lines: Vec<(usize, usize)>,
161    /// Per paragraph: whether a caret may stand at each character offset 0..=len (grapheme
162    /// boundaries), for logical caret movement.
163    pub boundaries: Vec<Vec<bool>>,
164}
165
166/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text.
167pub trait Fields {
168    fn field(&self, kind: &str) -> Option<String>;
169    /// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are
170    /// written the language's way.
171    fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
172        let _ = lang;
173        self.field(kind)
174    }
175}
176
177pub struct NoFields;
178impl Fields for NoFields {
179    fn field(&self, kind: &str) -> Option<String> {
180        if kind == "slidenum" {
181            Some("1".into())
182        } else {
183            None
184        }
185    }
186}
187
188pub struct Opts<'a> {
189    /// The text box: the preset's text rectangle in shape-local space.
190    pub rect: Rect,
191    pub fields: &'a dyn Fields,
192    /// Draw this text instead of the body (placeholder prompts), in this colour.
193    pub prompt_color: Option<Rgba>,
194    /// Ignore autofit shrinking (used to measure natural height).
195    pub no_shrink: bool,
196}
197
198struct Style {
199    face: Arc<FontFace>,
200    size: f64,
201    color: Rgba,
202    alpha: f64,
203    fake_bold: bool,
204    fake_italic: bool,
205    baseline: f64,
206    spacing: f64,
207    caps: Caps,
208    underline: Option<Rgba>,
209    strike: Option<Strike>,
210    highlight: Option<Rgba>,
211    outline: Option<(Rgba, f64)>,
212    link: bool,
213    gradient: Option<deckcraft_model::style::Gradient>,
214    effects: Option<RunEffects>,
215    /// "Regular", "Bold"… for the slot and fallback faces.
216    style_name: &'static str,
217    /// BCP 47 language (`a:rPr/@lang`), for shaping.
218    lang: Option<String>,
219    /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one.
220    ea: Option<String>,
221    cs: Option<String>,
222    /// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font
223    /// characters) are drawn in it.
224    sym: Option<String>,
225    /// Heading font (`+mj-…`): the theme's major font set applies.
226    major: bool,
227    /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
228    kern: bool,
229}
230
231/// One character of a paragraph after shaping.
232#[derive(Clone)]
233struct Cell {
234    ch: char,
235    style: usize,
236    face: usize,
237    /// Resolved bidi embedding level (before the line rules).
238    level: u8,
239    /// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid,
240    /// x, y). Empty for the other cells of a cluster.
241    glyphs: Vec<(u32, f64, f64)>,
242    /// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme.
243    adv: f64,
244    /// Index of the cell heading this character's shaping cluster.
245    head: usize,
246    /// A grapheme starts here: carets stand and lines break only before such cells.
247    boundary: bool,
248}
249
250impl Cell {
251    /// A line may start before this cell: a grapheme that starts a shaping cluster.
252    fn breakable(&self, i: usize) -> bool {
253        self.boundary && self.head == i
254    }
255}
256
257struct ParaShaped {
258    cells: Vec<Cell>,
259    styles: Vec<Style>,
260    faces: Vec<Arc<FontFace>>,
261    props: ParaProps,
262    /// Size of the paragraph's first run (spacing percentages, empty lines).
263    lead_size: f64,
264    lead_face: Arc<FontFace>,
265    bullet: Option<(String, Style)>,
266    #[allow(dead_code)]
267    level: u8,
268    /// Right-to-left paragraph (`a:pPr/@rtl`).
269    rtl: bool,
270}
271
272fn style_name(bold: bool, italic: bool) -> &'static str {
273    match (bold, italic) {
274        (true, true) => "Bold Italic",
275        (true, false) => "Bold",
276        (false, true) => "Italic",
277        (false, false) => "Regular",
278    }
279}
280
281fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Style {
282    let family = resolve::font_family(ctx, r);
283    let bold = r.bold.unwrap_or(false);
284    let italic = r.italic.unwrap_or(false);
285    let face = FontDb::global().document_face(&family, style_name(bold, italic));
286    let fake_bold = bold && face.weight < 550.0;
287    let fake_italic = italic && !face.italic;
288    let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
289    let (mut color, alpha, gradient) = match &r.fill {
290        Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
291        Some(Fill::Gradient(g)) => {
292            // Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
293            let mut g = g.clone();
294            for s in &mut g.stops {
295                s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
296            }
297            (
298                g.stops
299                    .first()
300                    .map(|s| ctx.color(&s.color, None))
301                    .unwrap_or(Rgba::BLACK),
302                1.0,
303                Some(g),
304            )
305        }
306        Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
307        _ => (resolve::text_color(ctx, r), 1.0, None),
308    };
309    if let Some(p) = prompt {
310        color = p;
311    }
312    let major = r.font.as_deref().is_some_and(|f| f.starts_with("+mj"));
313    let theme = &ctx.master.theme;
314    let slot_family = |explicit: &Option<String>, theme_ref: &str| {
315        let name = theme.font(explicit.as_deref().unwrap_or(theme_ref));
316        (!name.trim().is_empty()).then_some(name)
317    };
318    let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" });
319    let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" });
320    let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| {
321        r.underline_color
322            .as_ref()
323            .map(|c| ctx.color(c, None))
324            .unwrap_or(color)
325    });
326    let outline = r.outline.as_ref().and_then(|l| match &l.fill {
327        Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))),
328        _ => None,
329    });
330    Style {
331        face,
332        size,
333        color,
334        alpha,
335        fake_bold,
336        fake_italic,
337        baseline: r.baseline.unwrap_or(0.0),
338        spacing: r.spacing.unwrap_or(0.0) * scale,
339        caps: r.caps.unwrap_or(Caps::None),
340        underline,
341        strike: r.strike.filter(|s| *s != Strike::None),
342        highlight: r.highlight.as_ref().map(|c| ctx.color(c, None)),
343        outline,
344        link: r.link.is_some(),
345        gradient,
346        effects: run_effects(ctx, r, scale),
347        style_name: style_name(bold, italic),
348        lang: r.lang.clone().filter(|l| !l.is_empty()),
349        ea,
350        cs,
351        sym: r
352            .font_sym
353            .as_deref()
354            .map(|f| ctx.master.theme.font(f))
355            .filter(|f| !f.trim().is_empty()),
356        major,
357        kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
358    }
359}
360
361/// The faces to try for a cluster of `slot`/`script` in style `st`, in order: the slot's
362/// typeface, the theme's font for the script (`a:font[@script]`), then the run's Latin face.
363fn face_chain(
364    ctx: &Ctx,
365    st: &Style,
366    slot: FontSlot,
367    script: Option<ScriptTag>,
368) -> Vec<Arc<FontFace>> {
369    let db = FontDb::global();
370    let mut chain: Vec<Arc<FontFace>> = Vec::with_capacity(3);
371    let mut push = |family: &str| {
372        // Only families that exist: `FontDb::face` would substitute the default face.
373        if db.has_family(family) {
374            let f = db.face(family, st.style_name);
375            if !chain.iter().any(|c| Arc::ptr_eq(c, &f)) {
376                chain.push(f);
377            }
378        }
379    };
380    match slot {
381        FontSlot::EastAsian => st.ea.as_deref().into_iter().for_each(&mut push),
382        FontSlot::ComplexScript => st.cs.as_deref().into_iter().for_each(&mut push),
383        FontSlot::Latin => {}
384    }
385    if let Some(sc) = script.and_then(|t| std::str::from_utf8(&t).ok().map(String::from)) {
386        let set = if st.major {
387            &ctx.master.theme.fonts.major
388        } else {
389            &ctx.master.theme.fonts.minor
390        };
391        if let Some(f) = set.script(&sc) {
392            push(f);
393        }
394    }
395    if !chain.iter().any(|c| Arc::ptr_eq(c, &st.face)) {
396        chain.push(st.face.clone());
397    }
398    chain
399}
400
401fn spacing_pts(s: Option<Spacing>, size: f64, reduce: f64) -> f64 {
402    match s {
403        Some(Spacing::Pct(p)) => p * size * 1.2 * (1.0 - reduce),
404        Some(Spacing::Pts(v)) => v * (1.0 - reduce),
405        None => 0.0,
406    }
407}
408
409fn roman(mut n: u32, upper: bool) -> String {
410    let table = [
411        (1000, "m"),
412        (900, "cm"),
413        (500, "d"),
414        (400, "cd"),
415        (100, "c"),
416        (90, "xc"),
417        (50, "l"),
418        (40, "xl"),
419        (10, "x"),
420        (9, "ix"),
421        (5, "v"),
422        (4, "iv"),
423        (1, "i"),
424    ];
425    let mut s = String::new();
426    if n == 0 || n > 3999 {
427        return n.to_string();
428    }
429    for (v, r) in table {
430        while n >= v {
431            s.push_str(r);
432            n -= v;
433        }
434    }
435    if upper { s.to_uppercase() } else { s }
436}
437
438fn alpha_num(n: u32, upper: bool) -> String {
439    if n == 0 {
440        return String::new();
441    }
442    let mut n = n - 1;
443    let c = (b'a' + (n % 26) as u8) as char;
444    let reps = n / 26 + 1;
445    n = reps;
446    let s: String = std::iter::repeat_n(c, n as usize).collect();
447    if upper { s.to_uppercase() } else { s }
448}
449
450/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.".
451pub fn autonum(scheme: &str, n: u32) -> String {
452    let (core, upper) = if scheme.starts_with("romanUc") {
453        (roman(n, true), true)
454    } else if scheme.starts_with("romanLc") {
455        (roman(n, false), false)
456    } else if scheme.starts_with("alphaUc") {
457        (alpha_num(n, true), true)
458    } else if scheme.starts_with("alphaLc") {
459        (alpha_num(n, false), false)
460    } else if scheme.starts_with("circleNum") {
461        let c = char::from_u32(0x2460 + n.saturating_sub(1).min(19)).unwrap_or('•');
462        return c.to_string();
463    } else {
464        (n.to_string(), false)
465    };
466    let _ = upper;
467    if scheme.ends_with("ParenBoth") {
468        format!("({core})")
469    } else if scheme.ends_with("ParenR") {
470        format!("{core})")
471    } else if scheme.ends_with("Period") {
472        format!("{core}.")
473    } else if scheme.ends_with("Minus") {
474        format!("- {core} -")
475    } else {
476        core
477    }
478}
479
480fn shape_para(
481    ctx: &Ctx,
482    shape: &Shape,
483    body: &TextBody,
484    pi: usize,
485    scale: f64,
486    opts: &Opts,
487    number: Option<u32>,
488) -> ParaShaped {
489    let db = FontDb::global();
490    let Some(para) = body.paragraphs.get(pi) else {
491        let face = db.face("Inter", "Regular");
492        return ParaShaped {
493            cells: vec![],
494            styles: vec![],
495            faces: vec![],
496            props: ParaProps::default(),
497            lead_size: 18.0,
498            lead_face: face,
499            bullet: None,
500            level: 0,
501            rtl: false,
502        };
503    };
504    let props = resolve::para(ctx, shape, para);
505    let rtl = props.rtl.unwrap_or(false);
506    let mut styles: Vec<Style> = vec![];
507    let mut faces: Vec<Arc<FontFace>> = vec![];
508    // The logical paragraph: its text and each character's style.
509    let mut text = String::new();
510    let mut char_style: Vec<usize> = vec![];
511    for run in &para.runs {
512        let rp = resolve::run(ctx, shape, para, &run.props);
513        let st = make_style(ctx, &rp, scale, opts.prompt_color);
514        let t: String = match &run.kind {
515            RunKind::Text => run.text.clone(),
516            RunKind::Break => "\u{b}".into(),
517            RunKind::Field { field } => opts
518                .fields
519                .field_in(field, rp.lang.as_deref())
520                .unwrap_or_else(|| run.text.clone()),
521            RunKind::Math { .. } => run.text.clone(),
522        };
523        let si = styles.len();
524        styles.push(st);
525        char_style.extend(std::iter::repeat_n(si, t.chars().count()));
526        text.push_str(&t);
527    }
528    let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl);
529    let lead = para
530        .runs
531        .first()
532        .map(|r| resolve::run(ctx, shape, para, &r.props))
533        .unwrap_or_else(|| resolve::run(ctx, shape, para, &para.end_props));
534    let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
535    let lead_size = lead_style.size;
536    let lead_face = lead_style.face.clone();
537    // Bullet.
538    let bullet = if para.is_empty() && opts.prompt_color.is_none() {
539        None
540    } else {
541        match &props.bullet {
542            Some(Bullet::Char { char }) => {
543                let mut rp = lead.clone();
544                if let Some(f) = &props.bullet_font {
545                    rp.font = Some(f.clone());
546                }
547                rp.bold = Some(false);
548                rp.italic = Some(false);
549                if let Some(c) = &props.bullet_color {
550                    rp.fill = Some(Fill::solid(c.clone()));
551                }
552                rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
553                rp.underline = None;
554                rp.strike = None;
555                rp.highlight = None;
556                let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
557                st.baseline = 0.0;
558                // Symbol fonts (Wingdings) aren't available: map common private-use bullets.
559                let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
560                bullet_face(db, &mut st, &ch);
561
562                // A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
563                // at U+F000 + the character code as such fonts map them; otherwise common symbol
564                // bullets are mapped to Unicode look-alikes.
565                let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
566                    Some(c) => c.to_string(),
567                    None => map_symbol_bullet(char, props.bullet_font.as_deref()),
568                };
569                if !st.face.covers(ch.chars().next().unwrap_or('•')) {
570                    st.face = db.face("Inter", "Regular");
571                }
572                Some((ch, st))
573            }
574            Some(Bullet::AutoNum { scheme, start_at }) => {
575                let mut rp = lead.clone();
576                if let Some(c) = &props.bullet_color {
577                    rp.fill = Some(Fill::solid(c.clone()));
578                }
579                rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
580                rp.underline = None;
581                let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
582                let label = autonum(
583                    scheme,
584                    start_at
585                        .saturating_sub(1)
586                        .saturating_add(number.unwrap_or(1)),
587                );
588                bullet_face(db, &mut st, &label);
589                Some((label, st))
590            }
591            _ => None,
592        }
593    };
594    ParaShaped {
595        cells,
596        styles,
597        faces,
598        props,
599        lead_size,
600        lead_face,
601        bullet,
602        level: para.level,
603        rtl,
604    }
605}
606
607/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
608/// and other symbols missing from the text font), by the usual fallback.
609fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
610    let chars: Vec<char> = label.chars().collect();
611    if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
612        st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
613    }
614}
615
616/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
617/// script, face and shaping style, each shaped with the whole paragraph as context.
618/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
619fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
620    let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
621        let a = s.dir.to_radians();
622        TextShadow {
623            color: ctx.color(&s.color, None),
624            blur: s.blur.max(0.0) * scale,
625            dx: a.cos() * s.dist * scale,
626            dy: a.sin() * s.dist * scale,
627        }
628    });
629    let glow = r
630        .glow
631        .as_ref()
632        .filter(|g| g.radius > 0.0)
633        .map(|g| (ctx.color(&g.color, None), g.radius * scale));
634    (shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
635}
636
637fn shape_cells(
638    ctx: &Ctx,
639    text: &str,
640    char_style: &[usize],
641    styles: &[Style],
642    faces: &mut Vec<Arc<FontFace>>,
643    rtl: bool,
644) -> Vec<Cell> {
645    let db = FontDb::global();
646    let chars: Vec<char> = text.chars().collect();
647    let n = chars.len();
648    // Byte offset of each character (and of the end).
649    let mut byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
650    byte_of.push(text.len());
651    let char_at_byte: HashMap<usize, usize> =
652        byte_of.iter().enumerate().map(|(i, b)| (*b, i)).collect();
653    let levels = bidi::levels(text, &chars, rtl);
654    let scripts = script::resolve(&chars);
655    let mut boundary = vec![false; n];
656    let mut graphemes: Vec<(usize, usize)> = Vec::new();
657    for (b, g) in text.grapheme_indices(true) {
658        let Some(&i) = char_at_byte.get(&b) else {
659            continue;
660        };
661        if let Some(x) = boundary.get_mut(i) {
662            *x = true;
663        }
664        graphemes.push((i, i + g.chars().count()));
665    }
666    let face_index = |faces: &mut Vec<Arc<FontFace>>, f: &Arc<FontFace>| -> usize {
667        if let Some(i) = faces.iter().position(|x| Arc::ptr_eq(x, f)) {
668            return i;
669        }
670        faces.push(f.clone());
671        faces.len() - 1
672    };
673    // A face per grapheme cluster (never split between faces).
674    let mut char_face = vec![0usize; n];
675    let mut chains: HashMap<(usize, FontSlot, Option<ScriptTag>), Vec<Arc<FontFace>>> =
676        HashMap::new();
677    let mut prev: Option<Arc<FontFace>> = None;
678    for &(a, b) in &graphemes {
679        let Some(cluster) = chars.get(a..b) else {
680            continue;
681        };
682        let si = char_style.get(a).copied().unwrap_or(0);
683        let Some(st) = styles.get(si) else { continue };
684        let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
685        // Spaces, digits and punctuation stay in the face of the text before them when it can
686        // draw them, so a phrase isn't cut into items at every space.
687        // Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
688        let neutral = cluster.iter().all(|c| script::script_of(*c).is_none())
689            && !deckcraft_fonts::is_emoji_cluster(cluster)
690            && !cluster.contains(&'\u{FE0E}');
691        // A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there.
692        let sym_face = st
693            .sym
694            .as_deref()
695            .filter(|_| {
696                cluster
697                    .first()
698                    .is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c))
699            })
700            .filter(|f| db.has_family(f))
701            .map(|f| db.face(f, st.style_name))
702            .filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
703        let face = match (sym_face, prev.as_ref()) {
704            (Some(f), _) => f,
705            (None, Some(p))
706                if neutral
707                    && deckcraft_fonts::covers_cluster(p, cluster)
708                    && char_style.get(a.wrapping_sub(1)) == Some(&si) =>
709            {
710                p.clone()
711            }
712            (None, _) => {
713                let chain = chains
714                    .entry((si, slot, sc))
715                    .or_insert_with(|| face_chain(ctx, st, slot, sc));
716                db.cascade(cluster, chain, st.style_name)
717            }
718        };
719        let fi = face_index(faces, &face);
720        for f in char_face.get_mut(a..b).into_iter().flatten() {
721            *f = fi;
722        }
723        prev = Some(face);
724    }
725    let mut cells: Vec<Cell> = (0..n)
726        .map(|i| Cell {
727            ch: chars.get(i).copied().unwrap_or(' '),
728            style: char_style.get(i).copied().unwrap_or(0),
729            face: char_face.get(i).copied().unwrap_or(0),
730            level: levels.get(i).copied().unwrap_or(0),
731            glyphs: vec![],
732            adv: 0.0,
733            head: i,
734            boundary: boundary.get(i).copied().unwrap_or(true),
735        })
736        .collect();
737    // Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
738    let upper = |i: usize| {
739        styles
740            .get(char_style.get(i).copied().unwrap_or(0))
741            .is_some_and(|st| st.caps != Caps::None)
742    };
743    let lang = |i: usize| {
744        styles
745            .get(char_style.get(i).copied().unwrap_or(0))
746            .and_then(|st| st.lang.as_deref())
747    };
748    let kern = |i: usize| {
749        styles
750            .get(char_style.get(i).copied().unwrap_or(0))
751            .is_some_and(|st| st.kern)
752    };
753    let key = |i: usize| {
754        (
755            levels.get(i).copied(),
756            char_face.get(i).copied(),
757            scripts.get(i).map(|s| s.0),
758            upper(i),
759            lang(i),
760            kern(i),
761        )
762    };
763    let mut a = 0;
764    while a < n {
765        let mut b = a + 1;
766        while b < n && key(b) == key(a) {
767            b += 1;
768        }
769        shape_item(
770            &mut cells,
771            styles,
772            faces,
773            text,
774            &byte_of,
775            &char_at_byte,
776            a..b,
777            scripts.get(a).and_then(|s| s.0),
778            lang(a),
779            upper(a),
780        );
781        a = b;
782    }
783    cells
784}
785
786/// Size of superscript and subscript text relative to the run's size.
787const SUB_SUPER: f64 = 0.66;
788
789/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
790/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
791/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
792/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
793fn shaping_features(
794    cells: &[Cell],
795    styles: &[Style],
796    script: Option<ScriptTag>,
797) -> Vec<deckcraft_fonts::Feature> {
798    let mut f = vec![];
799    if !cells
800        .first()
801        .and_then(|c| styles.get(c.style))
802        .is_some_and(|st| st.kern)
803    {
804        f.extend(deckcraft_fonts::feature("-kern"));
805    }
806    let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
807    let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
808    if alphabetic && !emoji {
809        f.extend(deckcraft_fonts::feature("-liga"));
810        f.extend(deckcraft_fonts::feature("-clig"));
811    }
812    f
813}
814
815/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
816/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
817#[allow(clippy::too_many_arguments)]
818fn shape_item(
819    cells: &mut [Cell],
820    styles: &[Style],
821    faces: &[Arc<FontFace>],
822    text: &str,
823    byte_of: &[usize],
824    char_at_byte: &HashMap<usize, usize>,
825    range: std::ops::Range<usize>,
826    script: Option<ScriptTag>,
827    lang: Option<&str>,
828    upper: bool,
829) {
830    let Some(first) = cells.get(range.start) else {
831        return;
832    };
833    let (fi, level) = (first.face, first.level);
834    let Some(face) = faces.get(fi) else { return };
835    let rtl = level % 2 == 1;
836    let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else {
837        return;
838    };
839    let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
840    let params = ShapeParams {
841        features: &features,
842        rtl,
843        script,
844        language: lang,
845    };
846    let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &params, |ch| {
847        if upper {
848            ch.to_uppercase().next().unwrap_or(ch)
849        } else {
850            ch
851        }
852    });
853    let upem = face.upem.max(1.0);
854    let scale_of = |cell: &Cell| {
855        let st = styles.get(cell.style);
856        let size = st.map(|s| s.size).unwrap_or(18.0);
857        let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
858        let size = if small { size * 0.8 } else { size };
859        // Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
860        // two thirds too.
861        let size = if st.is_some_and(|s| s.baseline != 0.0) {
862            size * SUB_SUPER
863        } else {
864            size
865        };
866        size / upem
867    };
868    // Cluster heads in this item, ascending; each cluster spans to the next head.
869    let mut heads: Vec<usize> = glyphs
870        .iter()
871        .filter_map(|g| char_at_byte.get(&g.cluster).copied())
872        .filter(|h| range.contains(h))
873        .collect();
874    heads.sort_unstable();
875    heads.dedup();
876    let mut width: HashMap<usize, f64> = HashMap::new();
877    let mut lig: HashMap<usize, (u32, usize)> = HashMap::new();
878    for g in &glyphs {
879        let Some(&h) = char_at_byte.get(&g.cluster) else {
880            continue;
881        };
882        let Some(cell) = cells.get_mut(h) else {
883            continue;
884        };
885        if matches!(cell.ch, '\t' | '\u{b}' | '\n') {
886            continue;
887        }
888        let s = scale_of(cell);
889        let w = width.entry(h).or_insert(0.0);
890        cell.glyphs
891            .push((g.gid, *w + g.x_offset as f64 * s, -(g.y_offset as f64) * s));
892        *w += g.x_advance as f64 * s;
893        let e = lig.entry(h).or_insert((g.gid, 0));
894        e.1 += 1;
895    }
896    for (hi, &h) in heads.iter().enumerate() {
897        let end = heads
898            .get(hi + 1)
899            .copied()
900            .unwrap_or(range.end)
901            .min(range.end);
902        let w = width.get(&h).copied().unwrap_or(0.0);
903        for c in cells.get_mut(h..end).into_iter().flatten() {
904            c.head = h;
905        }
906        let stops: Vec<usize> = (h..end)
907            .filter(|&i| i == h || cells.get(i).is_some_and(|c| c.boundary))
908            .collect();
909        let m = stops.len().max(1);
910        // Visual widths of the cluster's graphemes, left to right.
911        let mut bounds = vec![0.0];
912        if m > 1
913            && let Some(&(gid, 1)) = lig.get(&h)
914        {
915            let s = cells.get(h).map(scale_of).unwrap_or(0.0);
916            let carets = deckcraft_fonts::ligature_carets(face, gid);
917            if carets.len() + 1 >= m {
918                bounds.extend(
919                    carets
920                        .iter()
921                        .take(m - 1)
922                        .map(|c| (*c as f64 * s).clamp(0.0, w)),
923                );
924            }
925        }
926        if bounds.len() != m {
927            bounds = (0..m).map(|j| w * j as f64 / m as f64).collect();
928        }
929        bounds.push(w);
930        for (j, &i) in stops.iter().enumerate() {
931            let vj = if rtl { m - 1 - j } else { j };
932            let share =
933                bounds.get(vj + 1).copied().unwrap_or(w) - bounds.get(vj).copied().unwrap_or(0.0);
934            if let Some(c) = cells.get_mut(i) {
935                c.adv = share.max(0.0);
936            }
937        }
938    }
939    // Tracking (character spacing) after every drawn grapheme and space.
940    for i in range {
941        let Some(c) = cells.get(i) else { continue };
942        let drawn = cells.get(c.head).is_some_and(|hd| !hd.glyphs.is_empty());
943        if c.boundary
944            && (drawn || c.ch == ' ')
945            && let Some(sp) = styles.get(c.style).map(|s| s.spacing)
946            && let Some(c) = cells.get_mut(i)
947        {
948            c.adv += sp;
949        }
950    }
951}
952
953/// Is `family` a symbol font (characters at U+F000 + code)?
954fn is_symbol_font(family: &str) -> bool {
955    let f = family.to_ascii_lowercase();
956    f.starts_with("wingdings")
957        || f == "symbol"
958        || f.starts_with("webdings")
959        || f.starts_with("marlett")
960}
961
962/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
963/// symbol font that `face` is (not a substitute) and it has the glyph.
964fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
965    let font = font?;
966    if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
967        return None;
968    }
969    let first = c.chars().next()?;
970    let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
971    if face.covers(pua) {
972        Some(pua)
973    } else if face.covers(first) {
974        Some(first)
975    } else {
976        None
977    }
978}
979
980/// The character that draws `c`, a character of text set in the symbol font `font` (Wingdings,
981/// Symbol, Webdings…: its code, or U+F000 + its code), with `face`: the installed symbol font's own
982/// glyph when `face` is that font, else a Unicode look-alike as bullets use. None when `font` is not
983/// a symbol font or `c` is not a symbol code. Metafile text takes this path.
984pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
985    let code = c as u32;
986    if !is_symbol_font(font) || !(code < 0x100 || (0xF000..=0xF0FF).contains(&code)) {
987        return None;
988    }
989    let plain = char::from_u32(code & 0xFF)?;
990    let s = plain.to_string();
991    Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| {
992        map_symbol_bullet(&s, Some(font))
993            .chars()
994            .next()
995            .unwrap_or('•')
996    }))
997}
998
999fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
1000    let f = font.unwrap_or("").to_ascii_lowercase();
1001    let first = c.chars().next().unwrap_or('•');
1002    if f.starts_with("webdings") {
1003        let m = match (first as u32) & 0xff {
1004            0x6e => '●',
1005            0x67 => '■',
1006            0x61 => '✓',
1007            0x72 => '✕',
1008            0x33 => '◀',
1009            0x34 => '▶',
1010            0x35 => '▲',
1011            0x36 => '▼',
1012            _ => '•',
1013        };
1014        return m.to_string();
1015    }
1016    if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first)
1017    {
1018        let code = (first as u32) & 0xff;
1019        // Wingdings 2 and 3 put different shapes at the same codes.
1020        if f.starts_with("wingdings 2") {
1021            let m = match code {
1022                0x97 | 0x98 => '●',
1023                0x9e..=0xa1 => '■',
1024                0xa2..=0xa5 => '◆',
1025                0xea..=0xf3 => '★',
1026                0xb7..=0xbf => '○',
1027                0x50 => '✓',
1028                _ => '•',
1029            };
1030            return m.to_string();
1031        }
1032        if f.starts_with("wingdings 3") {
1033            let m = match code {
1034                0x7d | 0x75 => '►',
1035                0x7c | 0x74 => '◄',
1036                0x70 => '▲',
1037                0x71 => '▼',
1038                0xc6 | 0xc7 => '➔',
1039                _ => '▶',
1040            };
1041            return m.to_string();
1042        }
1043        let m = match code {
1044            0xa7 => '▪',
1045            0xd8 => '➢',
1046            0xfc => '✓',
1047            0x76 => '❖',
1048            0x71 => '❑',
1049            0x6e => '■',
1050            0x6c => '●',
1051            0xa8 => '◆',
1052            0xb7 => '•',
1053            0x2d => '–',
1054            _ => '•',
1055        };
1056        return m.to_string();
1057    }
1058    // PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot).
1059    c.graphemes(true).next().unwrap_or("•").to_string()
1060}
1061
1062fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
1063    let g = deckcraft_fonts::shape(face, s, &[], |c| c);
1064    let k = size / face.upem.max(1.0);
1065    let mut x = 0.0;
1066    let mut out = vec![];
1067    for gl in g {
1068        out.push((gl.gid, x + gl.x_offset as f64 * k));
1069        x += gl.x_advance as f64 * k;
1070    }
1071    (x, out)
1072}
1073
1074/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
1075/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
1076/// and 40 pt), whatever the font's own ascent and descent.
1077///
1078/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
1079/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
1080/// Keep it a single tunable value, and re-measure on the other platform before changing it.
1081const LINE_PITCH: f64 = 1.2;
1082
1083/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
1084/// the font's Windows ascent and descent put the baseline.
1085fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
1086    let h = size * LINE_PITCH;
1087    (h * face.line_split, h * (1.0 - face.line_split))
1088}
1089
1090/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
1091/// syllables.
1092fn is_hangul(c: char) -> bool {
1093    matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
1094}
1095
1096/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
1097fn is_hanging_punct(c: char) -> bool {
1098    matches!(
1099        c,
1100        '、' | '。'
1101            | ','
1102            | '.'
1103            | ':'
1104            | ';'
1105            | '!'
1106            | '?'
1107            | ')'
1108            | '」'
1109            | '』'
1110            | '】'
1111            | '〕'
1112            | '〉'
1113            | '》'
1114            | '〙'
1115            | '〛'
1116            | '}'
1117            | ']'
1118            | '」'
1119            | '、'
1120            | '。'
1121    )
1122}
1123
1124/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
1125/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
1126/// Korean breaks only between words.
1127fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
1128    let n = text.chars().count();
1129    let mut allowed = vec![false; n + 1];
1130    let mut mandatory = vec![false; n + 1];
1131    let mut byte_to_char = std::collections::HashMap::new();
1132    for (ci, (b, _)) in text.char_indices().enumerate() {
1133        byte_to_char.insert(b, ci);
1134    }
1135    byte_to_char.insert(text.len(), n);
1136    for (b, op) in unicode_linebreak::linebreaks(text) {
1137        if let Some(&ci) = byte_to_char.get(&b) {
1138            if let Some(a) = allowed.get_mut(ci) {
1139                *a = true;
1140            }
1141            if op == unicode_linebreak::BreakOpportunity::Mandatory
1142                && let Some(m) = mandatory.get_mut(ci)
1143            {
1144                *m = true;
1145            }
1146        }
1147    }
1148    let chars: Vec<char> = text.chars().collect();
1149    for (i, w) in chars.windows(2).enumerate() {
1150        let (prev, next) = (w[0], w[1]);
1151        let keep = if is_hangul(prev) && is_hangul(next) {
1152            false
1153        } else {
1154            latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
1155        };
1156        if !keep
1157            && !mandatory.get(i + 1).copied().unwrap_or(false)
1158            && let Some(a) = allowed.get_mut(i + 1)
1159        {
1160            *a = false;
1161        }
1162    }
1163    (allowed, mandatory)
1164}
1165
1166struct RawLine {
1167    start: usize,
1168    end: usize,
1169    /// End including trailing spaces / the break char consumed.
1170    next: usize,
1171}
1172
1173fn break_lines(
1174    p: &ParaShaped,
1175    first_w: f64,
1176    rest_w: f64,
1177    wrap: bool,
1178    tab_stops: &[f64],
1179    default_tab: f64,
1180    x0_first: f64,
1181    x0_rest: f64,
1182) -> Vec<RawLine> {
1183    let text: String = p.cells.iter().map(|c| c.ch).collect();
1184    let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
1185    let hang = p.props.hanging_punct.unwrap_or(false);
1186    let n = p.cells.len();
1187    let mut lines = vec![];
1188    let mut start = 0;
1189    if n == 0 {
1190        return vec![RawLine {
1191            start: 0,
1192            end: 0,
1193            next: 0,
1194        }];
1195    }
1196    while start < n {
1197        let avail = if lines.is_empty() { first_w } else { rest_w };
1198        let x0 = if lines.is_empty() { x0_first } else { x0_rest };
1199        let mut x = 0.0;
1200        let mut last_break: Option<usize> = None;
1201        let mut i = start;
1202        let mut end = n;
1203        let mut next = n;
1204        while i < n {
1205            let c = p.cells.get(i).map(|c| c.ch).unwrap_or(' ');
1206            if c == '\u{b}' || c == '\n' {
1207                end = i;
1208                next = i + 1;
1209                break;
1210            }
1211            let adv = if c == '\t' {
1212                tab_advance(x0 + x, tab_stops, default_tab)
1213            } else {
1214                p.cells.get(i).map(|c| c.adv).unwrap_or(0.0)
1215            };
1216            let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j));
1217            if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
1218                last_break = Some(i);
1219            }
1220            // Closing East Asian punctuation hangs past the end instead of wrapping
1221            // (`hangingPunct`); the line breaks after it.
1222            if wrap
1223                && hang
1224                && x + adv > avail + 0.01
1225                && x <= avail + 0.01
1226                && is_hanging_punct(c)
1227                && i > start
1228            {
1229                end = i + 1;
1230                next = i + 1;
1231                break;
1232            }
1233            if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
1234                // Break at the last opportunity, or force mid-word (between clusters).
1235                let b = last_break.filter(|b| *b > start).unwrap_or_else(|| {
1236                    (start + 1..=i)
1237                        .rev()
1238                        .find(|&j| breakable(j))
1239                        .or_else(|| (i + 1..n).find(|&j| breakable(j)))
1240                        .unwrap_or(n)
1241                });
1242                end = b;
1243                next = b;
1244                // Trailing spaces belong to the line but don't count.
1245                while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') {
1246                    end -= 1;
1247                }
1248                break;
1249            }
1250            x += adv;
1251            i += 1;
1252        }
1253        if i >= n {
1254            end = n;
1255            next = n;
1256        }
1257        lines.push(RawLine { start, end, next });
1258        if next <= start {
1259            // No progress: force one char.
1260            let last = lines.len() - 1;
1261            let to = (start + 1..n)
1262                .find(|&j| p.cells.get(j).is_none_or(|c| c.breakable(j)))
1263                .unwrap_or(n);
1264            if let Some(l) = lines.get_mut(last) {
1265                l.end = to;
1266                l.next = to;
1267            }
1268            start = to;
1269        } else {
1270            start = next;
1271        }
1272        if next == n && p.cells.last().is_some_and(|c| c.ch == '\u{b}') && start >= n {
1273            // A trailing line break opens an empty last line.
1274            lines.push(RawLine {
1275                start: n,
1276                end: n,
1277                next: n,
1278            });
1279        }
1280    }
1281    lines
1282}
1283
1284fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 {
1285    for s in stops {
1286        if *s > x + 0.5 {
1287            return s - x;
1288        }
1289    }
1290    let d = if default_tab > 1.0 { default_tab } else { 72.0 };
1291    let next = ((x / d).floor() + 1.0) * d;
1292    (next - x).max(1.0)
1293}
1294
1295/// Lay out a shape's text body.
1296pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout {
1297    let bp = resolve::body(ctx, shape);
1298    let autofit = bp.autofit.unwrap_or(AutoFit::None);
1299    let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0);
1300    if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink)
1301        && l.overflow
1302    {
1303        // Binary search the largest scale that fits (steps like 90%, 80%… with line reduction).
1304        let (mut lo, mut hi) = (0.1_f64, 1.0_f64);
1305        let mut best = None;
1306        for _ in 0..12 {
1307            let mid = (lo + hi) / 2.0;
1308            let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2);
1309            let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red);
1310            if t.overflow {
1311                hi = mid;
1312            } else {
1313                lo = mid;
1314                best = Some(t);
1315            }
1316        }
1317        l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2));
1318    }
1319    l
1320}
1321
1322fn layout_scaled(
1323    ctx: &Ctx,
1324    shape: &Shape,
1325    body: &TextBody,
1326    bp: &BodyProps,
1327    opts: &Opts,
1328    scale: f64,
1329    reduce: f64,
1330) -> TextLayout {
1331    let r = opts.rect;
1332    let vert = bp.vert.unwrap_or(TextDir::Horizontal);
1333    let rotation = match vert {
1334        TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0,
1335        TextDir::Vertical270 => 270.0,
1336        TextDir::Horizontal => 0.0,
1337    } + bp.rot.unwrap_or(0.0);
1338    // For vertical text, lay out in a box with swapped dimensions centred on the text rect.
1339    let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 {
1340        let c = r.center();
1341        Rect::new(
1342            c.x - r.height() / 2.0,
1343            c.y - r.width() / 2.0,
1344            c.x + r.height() / 2.0,
1345            c.y + r.width() / 2.0,
1346        )
1347    } else {
1348        r
1349    };
1350    let inner = Rect::new(
1351        r.x0 + bp.inset_l.unwrap_or(7.2),
1352        r.y0 + bp.inset_t.unwrap_or(3.6),
1353        (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
1354        (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
1355    );
1356    // Text that follows a WordArt arch or circle is set on one line per paragraph.
1357    let follow = bp
1358        .warp
1359        .as_deref()
1360        .is_some_and(deckcraft_geom::warp::is_follow);
1361    let wrap = bp.wrap.unwrap_or(true)
1362        && !follow
1363        && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
1364    let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;
1365    let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);
1366    let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);
1367
1368    let mut out = TextLayout {
1369        inner,
1370        font_scale: scale,
1371        line_reduction: reduce,
1372        rotation,
1373        ..Default::default()
1374    };
1375    let mut y = 0.0;
1376    let mut numbering: Vec<u32> = vec![0; 9];
1377    let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![];
1378    let mut prev_after = 0.0;
1379    let mut shaped_paras: Vec<ParaShaped> = Vec::with_capacity(body.paragraphs.len());
1380    for (pi, para) in body.paragraphs.iter().enumerate() {
1381        let lvl = para.level.min(8) as usize;
1382        let pp = resolve::para(ctx, shape, para);
1383        let number = if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) && !para.is_empty() {
1384            if let Some(n) = numbering.get_mut(lvl) {
1385                *n += 1;
1386            }
1387            for deeper in numbering.iter_mut().skip(lvl + 1) {
1388                *deeper = 0;
1389            }
1390            numbering.get(lvl).copied()
1391        } else {
1392            if !para.is_empty() {
1393                for n in numbering.iter_mut().skip(lvl) {
1394                    *n = 0;
1395                }
1396            }
1397            None
1398        };
1399        shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number));
1400    }
1401    for (pi, p) in shaped_paras.iter().enumerate() {
1402        let props = &p.props;
1403        let margin = props.margin_left.unwrap_or(0.0).max(0.0);
1404        let indent = props.indent.unwrap_or(0.0);
1405        let mr = props.margin_right.unwrap_or(0.0).max(0.0);
1406        let bullet_w = p
1407            .bullet
1408            .as_ref()
1409            .map(|(s, st)| measure_str(&st.face, st.size, s).0)
1410            .unwrap_or(0.0);
1411        let first_x = if p.bullet.is_some() {
1412            let bx = (margin + indent).max(0.0);
1413            // A hanging bullet's text starts at the margin. When the hang reaches left of the box
1414            // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the
1415            // hang: the first line's text starts at -indent (later lines stay at the margin).
1416            let stop = margin.max(-indent);
1417            if indent < 0.0 && bx + bullet_w <= stop {
1418                stop
1419            } else {
1420                bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 }
1421            }
1422        } else {
1423            (margin + indent).max(0.0)
1424        };
1425        let rest_x = margin;
1426        let tabs: Vec<f64> = props
1427            .tabs
1428            .as_ref()
1429            .map(|t| t.iter().map(|s| s.pos).collect())
1430            .unwrap_or_default();
1431        let first_w = (col_w - first_x - mr).max(1.0);
1432        let rest_w = (col_w - rest_x - mr).max(1.0);
1433        let raw = break_lines(
1434            p,
1435            first_w,
1436            rest_w,
1437            wrap,
1438            &tabs,
1439            props.default_tab.unwrap_or(72.0),
1440            first_x,
1441            rest_x,
1442        );
1443        let before = if pi == 0 {
1444            0.0
1445        } else {
1446            spacing_pts(props.space_before, p.lead_size, reduce)
1447        };
1448        y += before.max(0.0) + prev_after;
1449        let first_line_idx = all_lines.len();
1450        for (li, rl) in raw.iter().enumerate() {
1451            // Line metrics: max ascent/descent over the line's styles (or the lead style if empty).
1452            let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64);
1453            for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) {
1454                if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) {
1455                    let sz = st.size;
1456                    let (a, d) = line_metrics(face, sz);
1457                    asc = asc.max(a);
1458                    desc = desc.max(d);
1459                    size = size.max(sz);
1460                }
1461            }
1462            if size == 0.0 {
1463                let (a, d) = line_metrics(&p.lead_face, p.lead_size);
1464                asc = a;
1465                desc = d;
1466                size = p.lead_size;
1467            }
1468            let natural = (asc + desc).max(size * 1.0);
1469            let lh = match props.line_spacing {
1470                Some(Spacing::Pct(f)) => natural * f.max(0.0) * (1.0 - reduce),
1471                Some(Spacing::Pts(v)) => v * (1.0 - reduce),
1472                None => natural * (1.0 - reduce),
1473            };
1474            let ratio = if natural > 0.0 { lh / natural } else { 1.0 };
1475            let top = y;
1476            let baseline = top + asc * ratio;
1477            y += lh;
1478            let x0 = if li == 0 { first_x } else { rest_x };
1479            let avail = (col_w - x0 - mr).max(0.0);
1480            let n = rl.end.saturating_sub(rl.start);
1481            let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]);
1482            // Logical advances; tabs measure from the start edge.
1483            let mut adv = Vec::with_capacity(n);
1484            let mut x = 0.0;
1485            for c in line_cells {
1486                let a = if c.ch == '\t' {
1487                    tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0))
1488                } else {
1489                    c.adv
1490                };
1491                adv.push(a);
1492                x += a;
1493            }
1494            let width = x;
1495            let align = props.align.unwrap_or(Align::Left);
1496            let last_line = li + 1 == raw.len();
1497            let slack = (avail - width).max(0.0);
1498            // Justification: kashida (justLow, Arabic), spaces, or every character.
1499            let stretch = !last_line && wrap && slack > 0.0;
1500            let mut kashida = vec![0.0; n];
1501            let mut filled = false;
1502            if stretch && align == Align::JustLow {
1503                let ops = kashida_opportunities(p, rl.start, rl.end);
1504                if !ops.is_empty() {
1505                    let each = slack / ops.len() as f64;
1506                    for k in ops {
1507                        if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) {
1508                            *e = each;
1509                            *a += each;
1510                        }
1511                    }
1512                    filled = true;
1513                }
1514            }
1515            if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) {
1516                let spaces = line_cells.iter().filter(|c| c.ch == ' ').count();
1517                if spaces > 0 {
1518                    for (a, c) in adv.iter_mut().zip(line_cells) {
1519                        if c.ch == ' ' {
1520                            *a += slack / spaces as f64;
1521                        }
1522                    }
1523                    filled = true;
1524                }
1525            }
1526            if align == Align::Distributed && n > 1 {
1527                for a in adv.iter_mut().take(n - 1) {
1528                    *a += slack / (n - 1) as f64;
1529                }
1530                filled = true;
1531            }
1532            let line_w: f64 = adv.iter().sum();
1533            // The line box, in column coordinates: the start edge is the left one for LTR and the
1534            // right one (S = W - rIns) for RTL paragraphs.
1535            let (box_l, box_r) = if p.rtl {
1536                (mr, col_w - x0)
1537            } else {
1538                (x0, col_w - mr)
1539            };
1540            let left = if filled {
1541                box_l
1542            } else {
1543                match align {
1544                    Align::Left => box_l,
1545                    Align::Right => box_r - line_w,
1546                    Align::Center => box_l + (box_r - box_l - line_w) / 2.0,
1547                    Align::Distributed if n <= 1 => box_l + slack / 2.0,
1548                    // Justified lines that aren't stretched (the last one) sit at the start edge.
1549                    Align::Justify | Align::JustLow | Align::Distributed if p.rtl => box_r - line_w,
1550                    Align::Justify | Align::JustLow | Align::Distributed => box_l,
1551                }
1552            };
1553            // Distance from the box's start edge to the text (bullets move with centred or
1554            // end-aligned text).
1555            let content_shift = if p.rtl {
1556                box_r - (left + line_w)
1557            } else {
1558                left - box_l
1559            };
1560            // Rules L1 + L2: this line's visual order.
1561            let chars: Vec<char> = line_cells.iter().map(|c| c.ch).collect();
1562            let raw_levels: Vec<u8> = line_cells.iter().map(|c| c.level).collect();
1563            let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl));
1564            let order = bidi::visual_order(&levels);
1565            let mut edges = vec![(left, left); n];
1566            let mut vx = left;
1567            for &k in &order {
1568                let a = adv.get(k).copied().unwrap_or(0.0);
1569                if let Some(e) = edges.get_mut(k) {
1570                    *e = (vx, vx + a);
1571                }
1572                vx += a;
1573            }
1574            let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1);
1575            let mut caret_x: Vec<f64> = (0..n)
1576                .map(|k| {
1577                    edges
1578                        .get(k)
1579                        .map(|e| if odd(k) { e.1 } else { e.0 })
1580                        .unwrap_or(left)
1581                })
1582                .collect();
1583            caret_x.push(match n.checked_sub(1) {
1584                Some(k) => edges
1585                    .get(k)
1586                    .map(|e| if odd(k) { e.0 } else { e.1 })
1587                    .unwrap_or(left),
1588                None if p.rtl => left + line_w,
1589                None => left,
1590            });
1591            let bullet = if li == 0 {
1592                p.bullet.as_ref().map(|(s, _)| {
1593                    // Centred and end-aligned text carries its bullet along.
1594                    let end_aligned = if p.rtl {
1595                        align == Align::Left
1596                    } else {
1597                        align == Align::Right
1598                    };
1599                    let shifted = align == Align::Center || end_aligned;
1600                    let b = if shifted && p.cells.is_empty() {
1601                        content_shift + x0 - bullet_w
1602                    } else {
1603                        (margin + indent).max(0.0) + if shifted { content_shift } else { 0.0 }
1604                    };
1605                    // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right.
1606                    let bx = if p.rtl { col_w - b - bullet_w } else { b };
1607                    (s.clone(), bx, baseline)
1608                })
1609            } else {
1610                None
1611            };
1612            out.content_width = out.content_width.max(width + x0 + mr);
1613            let boundary: Vec<bool> = line_cells.iter().map(|c| c.boundary).collect();
1614            all_lines.push((
1615                LineInfo {
1616                    para: pi,
1617                    start: rl.start,
1618                    end: rl.end,
1619                    top,
1620                    baseline,
1621                    bottom: y,
1622                    caret_x,
1623                    column: 0,
1624                    rtl: p.rtl,
1625                    levels,
1626                    edges,
1627                    boundary,
1628                    extent: (left, left + line_w),
1629                    start_offset: x0,
1630                },
1631                LinePlace { order, kashida },
1632                lh,
1633                bullet,
1634            ));
1635        }
1636        out.para_lines.push((first_line_idx, raw.len()));
1637        prev_after = spacing_pts(props.space_after, p.lead_size, reduce).max(0.0);
1638    }
1639    let total = y;
1640    out.content_height = total;
1641    // Columns: distribute lines by height.
1642    let col_h = if ncols > 1 {
1643        inner.height()
1644    } else {
1645        f64::INFINITY
1646    };
1647    let mut col = 0usize;
1648    let mut col_y0 = 0.0;
1649    for (li, _, _, _) in all_lines.iter_mut() {
1650        if ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < ncols {
1651            col += 1;
1652            col_y0 = li.top;
1653        }
1654        li.column = col;
1655        if col > 0 {
1656            let dy = col_y0;
1657            li.top -= dy;
1658            li.baseline -= dy;
1659            li.bottom -= dy;
1660            li.shift_x(col as f64 * (col_w + col_gap));
1661        }
1662    }
1663    let used_h = if ncols > 1 {
1664        all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max)
1665    } else {
1666        total
1667    };
1668    out.overflow = used_h > inner.height() + 0.5
1669        || (ncols > 1
1670            && all_lines
1671                .last()
1672                .is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5));
1673    let anchor = bp.anchor.unwrap_or(Anchor::Top);
1674    let dy = match anchor {
1675        Anchor::Top | Anchor::Justified | Anchor::Distributed => 0.0,
1676        Anchor::Middle => (inner.height() - used_h) / 2.0,
1677        Anchor::Bottom => inner.height() - used_h,
1678    };
1679    // anchorCtr: centre the block horizontally.
1680    let dx = if bp.anchor_ctr.unwrap_or(false) {
1681        ((inner.width() - out.content_width) / 2.0).max(0.0)
1682    } else {
1683        0.0
1684    };
1685    let ox = inner.x0 + dx;
1686    let oy = inner.y0 + dy;
1687    // Emit runs, in visual order.
1688    for (line, place, _, bullet) in all_lines.iter_mut() {
1689        line.top += oy;
1690        line.baseline += oy;
1691        line.bottom += oy;
1692        line.shift_x(ox);
1693        let Some(p) = shaped_paras.get(line.para) else {
1694            continue;
1695        };
1696        if let Some((s, bx, by)) = bullet.take()
1697            && let Some((_, st)) = p.bullet.as_ref()
1698        {
1699            let (_, g) = measure_str(&st.face, st.size, &s);
1700            out.runs.push(GlyphRun {
1701                face: st.face.clone(),
1702                size: st.size,
1703                color: st.color,
1704                glyphs: g
1705                    .into_iter()
1706                    .map(|(gid, x)| {
1707                        (
1708                            gid,
1709                            ox + bx
1710                                + x
1711                                + if line.column > 0 {
1712                                    line.column as f64 * (col_w + col_gap)
1713                                } else {
1714                                    0.0
1715                                },
1716                            by + oy,
1717                        )
1718                    })
1719                    .collect(),
1720                cells: vec![],
1721                fake_bold: false,
1722                fake_italic: false,
1723                outline: None,
1724                para: line.para,
1725                chars: (0, 0),
1726                link: false,
1727                alpha: st.alpha,
1728                gradient: None,
1729                effects: None,
1730            });
1731        }
1732        // Group visually consecutive cells by (style, face).
1733        let mut cur: Option<GlyphRun> = None;
1734        let mut cur_key = (usize::MAX, usize::MAX);
1735        for &k in &place.order {
1736            let ci = line.start + k;
1737            let Some(cell) = p.cells.get(ci) else {
1738                continue;
1739            };
1740            let Some(st) = p.styles.get(cell.style) else {
1741                continue;
1742            };
1743            let Some(&(cx, cx1)) = line.edges.get(k) else {
1744                continue;
1745            };
1746            let base_y = line.baseline - st.baseline * st.size;
1747            let size = if st.baseline != 0.0 {
1748                st.size * SUB_SUPER
1749            } else {
1750                st.size
1751            };
1752            let size = if st.caps == Caps::Small && cell.ch.is_lowercase() {
1753                size * 0.8
1754            } else {
1755                size
1756            };
1757            let key = (cell.style, cell.face);
1758            if key != cur_key {
1759                if let Some(r) = cur.take() {
1760                    out.runs.push(r);
1761                }
1762                let Some(face) = p.faces.get(cell.face) else {
1763                    continue;
1764                };
1765                cur = Some(GlyphRun {
1766                    face: face.clone(),
1767                    size,
1768                    color: st.color,
1769                    glyphs: vec![],
1770                    cells: vec![],
1771                    fake_bold: st.fake_bold,
1772                    fake_italic: st.fake_italic,
1773                    outline: st.outline,
1774                    para: line.para,
1775                    chars: (ci, ci + 1),
1776                    link: st.link,
1777                    alpha: st.alpha,
1778                    gradient: st.gradient.clone(),
1779                    effects: st.effects,
1780                });
1781                cur_key = key;
1782            }
1783            if let Some(r) = cur.as_mut() {
1784                r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1));
1785                if cell.head == ci && !cell.glyphs.is_empty() {
1786                    // The cluster's glyphs start at its leftmost cell; kashida (right-to-left
1787                    // only) fills the gap on the cluster's left with tatweel glyphs.
1788                    let cells_of = (k..line.end.saturating_sub(line.start))
1789                        .take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci));
1790                    let (mut cl, mut kash) = (cx, 0.0);
1791                    for j in cells_of {
1792                        cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx));
1793                        kash += place.kashida.get(j).copied().unwrap_or(0.0);
1794                    }
1795                    if kash > 0.0 {
1796                        let face = p.faces.get(cell.face);
1797                        let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0);
1798                        let tw = face
1799                            .map(|f| f.advance(tg) * size / f.upem.max(1.0))
1800                            .unwrap_or(0.0);
1801                        if tg != 0 && tw > 0.0 {
1802                            let count = ((kash / tw).ceil() as usize).clamp(1, 256);
1803                            for i in 0..count {
1804                                r.glyphs.push((
1805                                    tg,
1806                                    cl + (i as f64 * tw).min((kash - tw).max(0.0)),
1807                                    base_y,
1808                                ));
1809                                r.cells.push((ci, cell.ch));
1810                            }
1811                        }
1812                    }
1813                    for (gid, gx, gy) in &cell.glyphs {
1814                        r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
1815                        r.cells.push((ci, cell.ch));
1816                    }
1817                }
1818            }
1819            let adv = cx1 - cx;
1820            if adv <= 0.0 {
1821                continue;
1822            }
1823            if let Some(hl) = st.highlight {
1824                out.decos.push(Deco {
1825                    rect: Rect::new(cx, line.top, cx1, line.bottom),
1826                    color: hl,
1827                    behind: true,
1828                    para: line.para,
1829                });
1830            }
1831            if !cell.ch.is_whitespace() || st.underline.is_some() {
1832                if let Some(uc) = st.underline {
1833                    let t = (st.size * 0.06).max(0.5);
1834                    let uy = line.baseline + st.size * 0.12;
1835                    out.decos.push(Deco {
1836                        rect: Rect::new(cx, uy, cx1, uy + t),
1837                        color: uc,
1838                        behind: false,
1839                        para: line.para,
1840                    });
1841                }
1842                if let Some(sk) = st.strike {
1843                    let t = (st.size * 0.05).max(0.5);
1844                    let sy = line.baseline - st.size * 0.3;
1845                    out.decos.push(Deco {
1846                        rect: Rect::new(cx, sy, cx1, sy + t),
1847                        color: st.color,
1848                        behind: false,
1849                        para: line.para,
1850                    });
1851                    if sk == Strike::Double {
1852                        out.decos.push(Deco {
1853                            rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t),
1854                            color: st.color,
1855                            behind: false,
1856                            para: line.para,
1857                        });
1858                    }
1859                }
1860            }
1861        }
1862        if let Some(r) = cur.take() {
1863            out.runs.push(r);
1864        }
1865    }
1866    out.boundaries = shaped_paras
1867        .iter()
1868        .map(|p| {
1869            let mut b: Vec<bool> = p.cells.iter().map(|c| c.boundary).collect();
1870            b.push(true);
1871            if let Some(f) = b.first_mut() {
1872                *f = true;
1873            }
1874            b
1875        })
1876        .collect();
1877    out.lines = all_lines.into_iter().map(|(l, ..)| l).collect();
1878    // Merge adjacent decorations of the same colour on the same line.
1879    out.decos = merge_decos(std::mem::take(&mut out.decos));
1880    out
1881}
1882
1883fn merge_decos(v: Vec<Deco>) -> Vec<Deco> {
1884    let mut out: Vec<Deco> = Vec::with_capacity(v.len());
1885    for d in v {
1886        if let Some(last) = out.last_mut()
1887            && last.color == d.color
1888            && last.behind == d.behind
1889            && (last.rect.y0 - d.rect.y0).abs() < 0.01
1890            && (last.rect.y1 - d.rect.y1).abs() < 0.01
1891            && (last.rect.x1 - d.rect.x0).abs() < 0.5
1892        {
1893            last.rect.x1 = d.rect.x1;
1894            continue;
1895        }
1896        out.push(d);
1897    }
1898    out
1899}
1900
1901/// How a line's cells are placed: visual order (indices from the line start) and kashida widths
1902/// (the boxes, with justification, are in [`LineInfo::edges`]).
1903struct LinePlace {
1904    order: Vec<usize>,
1905    kashida: Vec<f64>,
1906}
1907
1908/// Kashida opportunities on a line (indices from the line start), at most one per word (its last
1909/// join, where elongation reads most naturally): the last cell of a right-to-left Arabic grapheme
1910/// that joins the next letter, in a face with a tatweel glyph, when the two aren't one ligature.
1911fn kashida_opportunities(p: &ParaShaped, start: usize, end: usize) -> Vec<usize> {
1912    let mut out: Vec<usize> = Vec::new();
1913    let mut word_has = false;
1914    for i in start..end {
1915        let Some(c) = p.cells.get(i) else { continue };
1916        if c.ch.is_whitespace() {
1917            word_has = false;
1918            continue;
1919        }
1920        if !c.boundary || c.level % 2 == 0 || !joins_following(c.ch) {
1921            continue;
1922        }
1923        // The next grapheme on the line.
1924        let Some(nx) = (i + 1..end).find(|&j| p.cells.get(j).is_some_and(|d| d.boundary)) else {
1925            continue;
1926        };
1927        let Some(d) = p.cells.get(nx) else { continue };
1928        let same_cluster = d.head == c.head || d.head != nx;
1929        let tatweel = p
1930            .faces
1931            .get(c.face)
1932            .is_some_and(|f| f.glyph_for('\u{0640}') != 0);
1933        if joins_preceding(d.ch) && d.face == c.face && !same_cluster && tatweel {
1934            // A later join in the same word replaces the earlier one.
1935            if word_has {
1936                out.pop();
1937            }
1938            out.push(nx - 1 - start);
1939            word_has = true;
1940        }
1941    }
1942    out
1943}
1944
1945/// Which side of a boundary a caret belongs to: `Downstream` sticks to the character after it
1946/// (the default), `Upstream` to the character before it, which differs at direction changes and
1947/// at the end of a wrapped line.
1948#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1949pub enum Affinity {
1950    Upstream,
1951    #[default]
1952    Downstream,
1953}
1954
1955/// Caret movement: through the text order, or across the screen.
1956#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1957pub enum MoveMode {
1958    /// Next/previous grapheme in logical order (`forward` = towards the end of the text).
1959    #[default]
1960    Logical,
1961    /// Next grapheme boundary to the right/left on screen (`forward` = right).
1962    Visual,
1963}
1964
1965impl LineInfo {
1966    fn len(&self) -> usize {
1967        self.end.saturating_sub(self.start)
1968    }
1969    fn odd(&self, k: usize) -> bool {
1970        self.levels.get(k).is_some_and(|l| l % 2 == 1)
1971    }
1972    /// Leading edge (where its direction begins) of character `k` of the line.
1973    fn leading(&self, k: usize) -> Option<f64> {
1974        self.edges
1975            .get(k)
1976            .map(|e| if self.odd(k) { e.1 } else { e.0 })
1977    }
1978    /// Trailing edge (where its direction ends) of character `k` of the line.
1979    fn trailing(&self, k: usize) -> Option<f64> {
1980        self.edges
1981            .get(k)
1982            .map(|e| if self.odd(k) { e.0 } else { e.1 })
1983    }
1984    fn shift_x(&mut self, dx: f64) {
1985        for x in &mut self.caret_x {
1986            *x += dx;
1987        }
1988        for e in &mut self.edges {
1989            e.0 += dx;
1990            e.1 += dx;
1991        }
1992        self.extent.0 += dx;
1993        self.extent.1 += dx;
1994    }
1995    /// Graphemes on the line as (first, end) character indices from the line start.
1996    fn graphemes(&self) -> Vec<(usize, usize)> {
1997        let n = self.len();
1998        let mut out = Vec::new();
1999        let mut k = 0;
2000        while k < n {
2001            let mut e = k + 1;
2002            while e < n && !self.boundary.get(e).copied().unwrap_or(true) {
2003                e += 1;
2004            }
2005            out.push((k, e));
2006            k = e;
2007        }
2008        out
2009    }
2010    /// Visual extent of characters `from..to` (k from the line start).
2011    fn grapheme_box(&self, from: usize, to: usize) -> Option<(f64, f64)> {
2012        let es = self.edges.get(from..to)?;
2013        let l = es.iter().map(|e| e.0).fold(f64::INFINITY, f64::min);
2014        let r = es.iter().map(|e| e.1).fold(f64::NEG_INFINITY, f64::max);
2015        (l <= r).then_some((l, r))
2016    }
2017    /// Visual x intervals covering paragraph characters `from..to` on this line, left to right
2018    /// and merged where they touch. Bidi text can make a logical range several boxes.
2019    pub fn spans(&self, from: usize, to: usize) -> Vec<(f64, f64)> {
2020        let a = from.max(self.start) - self.start;
2021        let b = to.min(self.end).saturating_sub(self.start);
2022        merge_spans(self.edges.get(a..b.max(a)).unwrap_or(&[]).to_vec())
2023    }
2024    /// Horizontal extent of paragraph characters `from..to` on this line.
2025    pub fn span(&self, from: usize, to: usize) -> Option<(f64, f64)> {
2026        let s = self.spans(from, to);
2027        Some((s.first()?.0, s.last()?.1))
2028    }
2029    /// Caret stops left to right: (x, character offset in the paragraph, affinity).
2030    fn visual_stops(&self) -> Vec<(f64, usize, Affinity)> {
2031        let mut gs: Vec<(f64, f64, usize, usize, bool)> = self
2032            .graphemes()
2033            .into_iter()
2034            .filter_map(|(a, b)| {
2035                self.grapheme_box(a, b)
2036                    .map(|(l, r)| (l, r, a, b, self.odd(a)))
2037            })
2038            .collect();
2039        gs.sort_by(|x, y| x.0.total_cmp(&y.0));
2040        let mut out: Vec<(f64, usize, Affinity)> = Vec::with_capacity(gs.len() * 2 + 1);
2041        let mut push = |s: (f64, usize, Affinity)| {
2042            if !out
2043                .last()
2044                .is_some_and(|l: &(f64, usize, Affinity)| (l.0 - s.0).abs() < 0.01 && l.1 == s.1)
2045            {
2046                out.push(s);
2047            }
2048        };
2049        for (l, r, a, b, rtl) in gs {
2050            let (a, b) = (self.start + a, self.start + b);
2051            if rtl {
2052                push((l, b, Affinity::Upstream));
2053                push((r, a, Affinity::Downstream));
2054            } else {
2055                push((l, a, Affinity::Downstream));
2056                push((r, b, Affinity::Upstream));
2057            }
2058        }
2059        if out.is_empty() {
2060            out.push((
2061                self.caret_x.first().copied().unwrap_or(0.0),
2062                self.start,
2063                Affinity::Downstream,
2064            ));
2065        }
2066        out
2067    }
2068}
2069
2070/// Non-empty x intervals sorted left to right, merged where they touch.
2071fn merge_spans(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
2072    v.retain(|e| e.1 > e.0);
2073    v.sort_by(|x, y| x.0.total_cmp(&y.0));
2074    let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len());
2075    for e in v {
2076        match out.last_mut() {
2077            Some(last) if e.0 <= last.1 + 0.01 => last.1 = last.1.max(e.1),
2078            _ => out.push(e),
2079        }
2080    }
2081    out
2082}
2083
2084/// A caret position: paragraph and character offset within it.
2085#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
2086pub struct Pos {
2087    pub para: usize,
2088    pub ch: usize,
2089}
2090
2091impl TextLayout {
2092    /// The line containing a caret position (the later line at a soft wrap boundary).
2093    pub fn line_of(&self, pos: Pos) -> Option<usize> {
2094        self.line_of_affine(pos, Affinity::Downstream)
2095    }
2096    /// The line containing a caret position: at a soft wrap boundary, the earlier line for an
2097    /// upstream caret and the later one for a downstream caret.
2098    pub fn line_of_affine(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
2099        let mut found = None;
2100        for (i, l) in self.lines.iter().enumerate() {
2101            if l.para == pos.para && pos.ch >= l.start && pos.ch <= l.end {
2102                if affinity == Affinity::Upstream && pos.ch == l.end && pos.ch > l.start {
2103                    return Some(i);
2104                }
2105                found = Some(i);
2106                if pos.ch < l.end {
2107                    break;
2108                }
2109            }
2110        }
2111        found
2112            .or_else(|| {
2113                self.lines
2114                    .iter()
2115                    .rposition(|l| l.para == pos.para && l.start <= pos.ch)
2116            })
2117            .or_else(|| self.lines.iter().rposition(|l| l.para == pos.para))
2118    }
2119    /// Caret line segment (x, top, bottom) in shape-local, unrotated coordinates (downstream).
2120    pub fn caret(&self, pos: Pos) -> Option<(f64, f64, f64)> {
2121        self.caret_at(pos, Affinity::Downstream)
2122    }
2123    /// Caret line segment (x, top, bottom) for a position and affinity: downstream at the leading
2124    /// edge of the character after the boundary, upstream at the trailing edge of the one before.
2125    pub fn caret_at(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
2126        let li = self.line_of_affine(pos, affinity)?;
2127        let l = self.lines.get(li)?;
2128        let k = pos.ch.clamp(l.start, l.end) - l.start;
2129        let x = match affinity {
2130            Affinity::Downstream if k < l.len() => l.leading(k),
2131            Affinity::Upstream if k > 0 => l.trailing(k - 1),
2132            _ => None,
2133        };
2134        let x = x
2135            .or_else(|| l.caret_x.get(k).copied())
2136            .or(l.caret_x.last().copied())?;
2137        Some((x, l.top, l.bottom))
2138    }
2139    /// Nearest caret position for a point.
2140    pub fn hit(&self, p: Point) -> Pos {
2141        self.hit_affinity(p).0
2142    }
2143    /// Nearest caret position for a point, with the affinity that keeps the caret on the side of
2144    /// the character that was hit (bidi text has two carets at a direction change).
2145    pub fn hit_affinity(&self, p: Point) -> (Pos, Affinity) {
2146        let mut best: Option<&LineInfo> = None;
2147        for l in &self.lines {
2148            if p.y >= l.top && p.y < l.bottom {
2149                // In multi-column bodies pick the line whose column contains x.
2150                if best.is_none()
2151                    || p.x
2152                        >= l.extent
2153                            .0
2154                            .min(l.caret_x.first().copied().unwrap_or(l.extent.0))
2155                            - 4.0
2156                {
2157                    best = Some(l);
2158                }
2159            }
2160        }
2161        let l = match best {
2162            Some(l) => l,
2163            None => {
2164                let first = self.lines.first();
2165                let last = self.lines.last();
2166                match (first, last) {
2167                    (Some(f), _) if p.y < f.top => f,
2168                    (_, Some(l)) => l,
2169                    _ => return (Pos::default(), Affinity::Downstream),
2170                }
2171            }
2172        };
2173        let mut gs: Vec<(f64, f64, usize, usize, bool)> = l
2174            .graphemes()
2175            .into_iter()
2176            .filter_map(|(a, b)| {
2177                l.grapheme_box(a, b)
2178                    .map(|(x0, x1)| (x0, x1, a, b, l.odd(a)))
2179            })
2180            .collect();
2181        if gs.is_empty() {
2182            return (
2183                Pos {
2184                    para: l.para,
2185                    ch: l.start,
2186                },
2187                Affinity::Downstream,
2188            );
2189        }
2190        gs.sort_by(|x, y| x.0.total_cmp(&y.0));
2191        let hit = gs
2192            .iter()
2193            .find(|g| p.x >= g.0 && p.x <= g.1 && g.1 > g.0)
2194            .copied();
2195        let (g, left_half) = match hit {
2196            Some(g) => (g, p.x < (g.0 + g.1) / 2.0),
2197            None => match (gs.first().copied(), gs.last().copied()) {
2198                (Some(f), _) if p.x < f.0 => (f, true),
2199                (_, Some(z)) if p.x > z.1 => (z, false),
2200                // Between zero-width graphemes: the nearest one.
2201                _ => match gs
2202                    .iter()
2203                    .min_by(|a, b| {
2204                        ((a.0 + a.1) / 2.0 - p.x)
2205                            .abs()
2206                            .total_cmp(&((b.0 + b.1) / 2.0 - p.x).abs())
2207                    })
2208                    .copied()
2209                {
2210                    Some(g) => (g, p.x < (g.0 + g.1) / 2.0),
2211                    None => {
2212                        return (
2213                            Pos {
2214                                para: l.para,
2215                                ch: l.start,
2216                            },
2217                            Affinity::Downstream,
2218                        );
2219                    }
2220                },
2221            },
2222        };
2223        let (_, _, a, b, rtl) = g;
2224        let (before, after) = (
2225            Pos {
2226                para: l.para,
2227                ch: l.start + a,
2228            },
2229            Pos {
2230                para: l.para,
2231                ch: l.start + b,
2232            },
2233        );
2234        // The leading half of a character puts the caret before it, the trailing half after it.
2235        if left_half != rtl {
2236            (before, Affinity::Downstream)
2237        } else {
2238            (after, Affinity::Upstream)
2239        }
2240    }
2241    /// Move a caret one grapheme: logically (`forward` = towards the end of the text, crossing
2242    /// paragraphs) or visually (`forward` = to the right on screen, continuing on the next or
2243    /// previous line in the paragraph's direction at the line's edge).
2244    pub fn move_caret(
2245        &self,
2246        pos: Pos,
2247        affinity: Affinity,
2248        forward: bool,
2249        mode: MoveMode,
2250    ) -> (Pos, Affinity) {
2251        if mode == MoveMode::Visual
2252            && let Some(li) = self.line_of_affine(pos, affinity)
2253            && let Some(l) = self.lines.get(li)
2254        {
2255            let stops = l.visual_stops();
2256            let cur_x = self.caret_at(pos, affinity).map(|c| c.0).unwrap_or(0.0);
2257            let idx = stops
2258                .iter()
2259                .position(|s| s.1 == pos.ch && s.2 == affinity)
2260                .or_else(|| stops.iter().position(|s| s.1 == pos.ch))
2261                .or_else(|| {
2262                    stops
2263                        .iter()
2264                        .enumerate()
2265                        .min_by(|a, b| (a.1.0 - cur_x).abs().total_cmp(&(b.1.0 - cur_x).abs()))
2266                        .map(|(i, _)| i)
2267                });
2268            if let Some(idx) = idx {
2269                let mut j = idx;
2270                loop {
2271                    let next = if forward {
2272                        j.checked_add(1)
2273                    } else {
2274                        j.checked_sub(1)
2275                    };
2276                    let Some(nj) = next else { break };
2277                    let Some(s) = stops.get(nj) else { break };
2278                    // Skip stops that wouldn't move the caret on screen or in the text.
2279                    if s.1 != pos.ch && (s.0 - cur_x).abs() > 0.01 {
2280                        return (
2281                            Pos {
2282                                para: l.para,
2283                                ch: s.1,
2284                            },
2285                            s.2,
2286                        );
2287                    }
2288                    j = nj;
2289                }
2290            }
2291            // Off the line's edge: on to the neighbouring line in reading order.
2292            let logical_forward = forward != l.rtl;
2293            let target = if logical_forward {
2294                li.checked_add(1)
2295            } else {
2296                li.checked_sub(1)
2297            };
2298            return match target.and_then(|t| self.lines.get(t)) {
2299                Some(t) if logical_forward => (
2300                    Pos {
2301                        para: t.para,
2302                        ch: t.start,
2303                    },
2304                    Affinity::Downstream,
2305                ),
2306                Some(t) => (
2307                    Pos {
2308                        para: t.para,
2309                        ch: t.end,
2310                    },
2311                    if t.para == l.para {
2312                        Affinity::Upstream
2313                    } else {
2314                        Affinity::Downstream
2315                    },
2316                ),
2317                None => (pos, affinity),
2318            };
2319        }
2320        let Some(b) = self.boundaries.get(pos.para) else {
2321            return (pos, affinity);
2322        };
2323        let len = b.len().saturating_sub(1);
2324        let ch = pos.ch.min(len);
2325        let next = if forward {
2326            (ch + 1..=len).find(|&k| b.get(k).copied().unwrap_or(true))
2327        } else {
2328            (0..ch).rev().find(|&k| b.get(k).copied().unwrap_or(true))
2329        };
2330        match next {
2331            Some(k) => (
2332                Pos {
2333                    para: pos.para,
2334                    ch: k,
2335                },
2336                Affinity::Downstream,
2337            ),
2338            None if forward && pos.para + 1 < self.boundaries.len() => (
2339                Pos {
2340                    para: pos.para + 1,
2341                    ch: 0,
2342                },
2343                Affinity::Downstream,
2344            ),
2345            None if !forward && pos.para > 0 => {
2346                let prev = pos.para - 1;
2347                (
2348                    Pos {
2349                        para: prev,
2350                        ch: self
2351                            .boundaries
2352                            .get(prev)
2353                            .map(|b| b.len().saturating_sub(1))
2354                            .unwrap_or(0),
2355                    },
2356                    Affinity::Downstream,
2357                )
2358            }
2359            None => (Pos { para: pos.para, ch }, Affinity::Downstream),
2360        }
2361    }
2362    /// Selection highlight rectangles between two positions: per line, the union of the visual
2363    /// boxes of the selected characters (one logical range can be several boxes in bidi text).
2364    pub fn selection_rects(&self, a: Pos, b: Pos) -> Vec<Rect> {
2365        let (s, e) = if a <= b { (a, b) } else { (b, a) };
2366        let mut out = vec![];
2367        for l in &self.lines {
2368            let lp = Pos {
2369                para: l.para,
2370                ch: l.start,
2371            };
2372            let le = Pos {
2373                para: l.para,
2374                ch: l.end,
2375            };
2376            if le < s || lp > e {
2377                continue;
2378            }
2379            let from = if s > lp { s.ch } else { l.start };
2380            let to = if e < le { e.ch } else { l.end };
2381            let mut spans = l.spans(from, to);
2382            // Selecting across a paragraph end shows a small extra box for the newline, at the
2383            // paragraph's end side.
2384            if e.para > l.para && to == l.end {
2385                let end_x = l.caret_x.last().copied().unwrap_or(l.extent.1);
2386                spans.push(if l.rtl {
2387                    (end_x - 6.0, end_x)
2388                } else {
2389                    (end_x, end_x + 6.0)
2390                });
2391                spans = merge_spans(spans);
2392            }
2393            for (x0, x1) in spans {
2394                if x1 > x0 {
2395                    out.push(Rect::new(x0, l.top, x1, l.bottom));
2396                }
2397            }
2398        }
2399        out
2400    }
2401    /// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
2402    /// visual span, a little below each line's baseline (PowerPoint underlines the composition
2403    /// in place). A composition that wraps gets one underline per line.
2404    pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
2405        let (s, e) = if a <= b { (a, b) } else { (b, a) };
2406        let mut out = vec![];
2407        if s == e {
2408            return out;
2409        }
2410        for l in &self.lines {
2411            let lp = Pos {
2412                para: l.para,
2413                ch: l.start,
2414            };
2415            let le = Pos {
2416                para: l.para,
2417                ch: l.end,
2418            };
2419            if le <= s || lp >= e {
2420                continue;
2421            }
2422            let from = if s > lp { s.ch } else { l.start };
2423            let to = if e < le { e.ch } else { l.end };
2424            // Under the baseline, in the descent, never below the line.
2425            let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
2426            for (x0, x1) in l.spans(from, to) {
2427                if x1 > x0 {
2428                    out.push((x0, x1, y));
2429                }
2430            }
2431        }
2432        out
2433    }
2434    /// Vertical caret movement: the position on the line above/below closest to `x`.
2435    pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
2436        self.vertical_affine(pos, Affinity::Downstream, down, x).0
2437    }
2438    /// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End
2439    /// leaves it, after the previous line's trailing spaces) belongs to the line before.
2440    pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
2441        if affinity == Affinity::Upstream && pos.ch > 0 {
2442            for (i, w) in self.lines.windows(2).enumerate() {
2443                if let [a, b] = w
2444                    && b.para == pos.para
2445                    && a.para == pos.para
2446                    && b.start == pos.ch
2447                {
2448                    return Some(i);
2449                }
2450            }
2451        }
2452        self.line_of_affine(pos, affinity)
2453    }
2454    /// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`.
2455    pub fn vertical_affine(
2456        &self,
2457        pos: Pos,
2458        affinity: Affinity,
2459        down: bool,
2460        x: f64,
2461    ) -> (Pos, Affinity) {
2462        let Some(li) = self.line_index(pos, affinity) else {
2463            return (pos, affinity);
2464        };
2465        let target = if down {
2466            li.checked_add(1)
2467        } else {
2468            li.checked_sub(1)
2469        };
2470        let Some(l) = target.and_then(|t| self.lines.get(t)) else {
2471            return (pos, affinity);
2472        };
2473        let p = Point::new(x, (l.top + l.bottom) / 2.0);
2474        let mut h = self.hit(p);
2475        if h.para != l.para {
2476            h.para = l.para;
2477        }
2478        h.ch = h.ch.clamp(l.start, l.end);
2479        (h, Affinity::Downstream)
2480    }
2481    /// Start/end of the visual line containing `pos` (logical: Home/End).
2482    pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
2483        self.line_bounds_affine(pos, Affinity::Downstream)
2484    }
2485    /// Start/end of the visual line a caret with this affinity is on.
2486    pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) {
2487        match self
2488            .line_index(pos, affinity)
2489            .and_then(|i| self.lines.get(i))
2490        {
2491            Some(l) => (
2492                Pos {
2493                    para: l.para,
2494                    ch: l.start,
2495                },
2496                Pos {
2497                    para: l.para,
2498                    ch: l.end,
2499                },
2500            ),
2501            None => (pos, pos),
2502        }
2503    }
2504    /// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start
2505    /// of the next line with upstream affinity (so it is still drawn on this line), else the end
2506    /// of the paragraph.
2507    pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) {
2508        let Some(li) = self.line_index(pos, affinity) else {
2509            return (pos, Affinity::Downstream);
2510        };
2511        let Some(l) = self.lines.get(li) else {
2512            return (pos, Affinity::Downstream);
2513        };
2514        match self.lines.get(li + 1) {
2515            Some(n) if n.para == l.para && n.start > l.start => (
2516                Pos {
2517                    para: l.para,
2518                    ch: n.start,
2519                },
2520                Affinity::Upstream,
2521            ),
2522            _ => (
2523                Pos {
2524                    para: l.para,
2525                    ch: l.end,
2526                },
2527                Affinity::Downstream,
2528            ),
2529        }
2530    }
2531    /// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end
2532    /// of the line it belongs to.
2533    pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
2534        let li = self.line_index(pos, affinity)?;
2535        let l = self.lines.get(li)?;
2536        if pos.ch > l.end {
2537            let x = l.caret_x.last().copied()?;
2538            return Some((x, l.top, l.bottom));
2539        }
2540        self.caret_at(pos, affinity)
2541    }
2542    /// Bounding box of all glyphs/lines (shape-local).
2543    pub fn bounds(&self) -> Rect {
2544        let mut r: Option<Rect> = None;
2545        for l in &self.lines {
2546            let lr = Rect::new(l.extent.0, l.top, l.extent.1.max(l.extent.0), l.bottom);
2547            r = Some(r.map(|a| a.union(lr)).unwrap_or(lr));
2548        }
2549        r.unwrap_or(self.inner)
2550    }
2551}
2552
2553/// Height the shape needs so its text fits (Resize shape to fit text): text height + insets.
2554pub fn fit_height(
2555    ctx: &Ctx,
2556    shape: &Shape,
2557    body: &TextBody,
2558    rect: Rect,
2559    fields: &dyn Fields,
2560) -> f64 {
2561    let bp = resolve::body(ctx, shape);
2562    let l = layout(
2563        ctx,
2564        shape,
2565        body,
2566        &Opts {
2567            rect,
2568            fields,
2569            prompt_color: None,
2570            no_shrink: true,
2571        },
2572    );
2573    l.content_height + bp.inset_t.unwrap_or(3.6) + bp.inset_b.unwrap_or(3.6)
2574}
2575
2576/// Width a non-wrapping text box needs.
2577pub fn fit_width(
2578    ctx: &Ctx,
2579    shape: &Shape,
2580    body: &TextBody,
2581    rect: Rect,
2582    fields: &dyn Fields,
2583) -> f64 {
2584    let bp = resolve::body(ctx, shape);
2585    let wide = Rect::new(rect.x0, rect.y0, rect.x0 + 100_000.0, rect.y1);
2586    let mut s2 = shape.clone();
2587    if let Some(t) = s2.text.as_mut() {
2588        t.body.wrap = Some(false);
2589    }
2590    let l = layout(
2591        ctx,
2592        &s2,
2593        body,
2594        &Opts {
2595            rect: wide,
2596            fields,
2597            prompt_color: None,
2598            no_shrink: true,
2599        },
2600    );
2601    let w = l
2602        .lines
2603        .iter()
2604        .map(|li| li.start_offset + (li.extent.1 - li.extent.0))
2605        .fold(0.0, f64::max);
2606    w + bp.inset_l.unwrap_or(7.2) + bp.inset_r.unwrap_or(7.2) + 1.0
2607}
2608
2609#[cfg(test)]
2610mod tests;