Skip to main content

deckmint/objects/
text.rs

1use crate::enums::{AlignH, AlignV};
2use crate::types::{AnimationEffect, Coord, FieldType, GlowProps, GradientFill, HyperlinkProps, Margin, PositionProps, ShadowProps, ShapeLineProps, TextOutlineProps};
3
4/// A fully resolved text object placed on a slide.
5#[derive(Debug, Clone)]
6pub struct TextObject {
7    /// Internal object name used for relationship and shape identification.
8    pub object_name: String,
9    /// Ordered list of text runs that make up the content of this text box.
10    pub text: Vec<TextRun>,
11    /// Layout and paragraph-level formatting options for this text box.
12    pub options: TextOptions,
13}
14
15/// A single run of text with optional per-run formatting.
16#[derive(Debug, Clone)]
17pub struct TextRun {
18    /// The text content of this run.
19    pub text: String,
20    /// Character-level formatting options for this run.
21    pub options: TextRunOptions,
22    /// True if a line-break should follow this run.
23    pub break_line: bool,
24    /// True if a soft line-break (`<a:br>`) should precede this run.
25    pub soft_break_before: bool,
26    /// When set, this run renders as an `<a:fld>` field (e.g. slide number) instead of `<a:r>`.
27    pub field: Option<FieldType>,
28    /// Pre-rendered OMML equation XML (the `<a14:m>` element). When set, this run
29    /// renders as an inline equation instead of a normal text run.
30    pub equation_omml: Option<String>,
31}
32
33impl TextRun {
34    /// Create a new text run with the given content and default formatting.
35    pub fn new(text: impl Into<String>) -> Self {
36        TextRun { text: text.into(), options: TextRunOptions::default(), break_line: false, soft_break_before: false, field: None, equation_omml: None }
37    }
38
39    /// Create a text run containing a LaTeX equation.
40    ///
41    /// The equation is converted to native OMML (editable in PowerPoint).
42    /// Requires the `math` feature.
43    ///
44    /// ```rust,no_run
45    /// use deckmint::objects::text::TextRun;
46    /// let run = TextRun::equation(r"x^2 + 3x - 7").unwrap();
47    /// ```
48    #[cfg(feature = "math")]
49    pub fn equation(latex: &str) -> Result<Self, crate::error::PptxError> {
50        let omml = deckmint_math::latex_to_omml(latex)
51            .map_err(|e| crate::error::PptxError::InvalidArgument(
52                format!("equation conversion failed: {e}"),
53            ))?;
54        Ok(TextRun {
55            text: String::new(),
56            options: TextRunOptions::default(),
57            break_line: false,
58            soft_break_before: false,
59            field: None,
60            equation_omml: Some(omml),
61        })
62    }
63}
64
65/// Per-run formatting (character-level).
66#[derive(Debug, Clone, Default)]
67pub struct TextRunOptions {
68    /// Whether the text is bold.
69    pub bold: Option<bool>,
70    /// Whether the text is italic.
71    pub italic: Option<bool>,
72    /// OOXML underline style: "sng", "dbl", "dash", "dashHeavy", "dashLong", "dashLongHeavy",
73    /// "dotDash", "dotDashHeavy", "dotDotDash", "dotDotDashHeavy", "dotted", "heavy",
74    /// "wavy", "wavyDbl", "wavyHeavy". Use "sng" for basic underline.
75    pub underline: Option<String>,
76    /// Underline color as 6-digit hex, no `#` prefix.
77    pub underline_color: Option<String>,
78    /// Strike style: "sng" (single) or "dbl" (double). Use `TextRunBuilder::strike()` for single.
79    pub strike: Option<String>,
80    /// Font size in points.
81    pub font_size: Option<f64>,
82    /// Font face name, e.g. "Arial" or "Calibri".
83    pub font_face: Option<String>,
84    /// Font color as 6-digit hex, no `#` prefix.
85    pub color: Option<String>,
86    /// Text transparency 0–100 (0 = opaque, 100 = fully transparent).
87    pub transparency: Option<f64>,
88    /// Highlight color as 6-digit hex (no #). Rendered as `<a:highlight>`.
89    pub highlight: Option<String>,
90    /// Character spacing in points (positive expands, negative condenses).
91    pub char_spacing: Option<f64>,
92    /// Whether this run is superscript.
93    pub superscript: bool,
94    /// Whether this run is subscript.
95    pub subscript: bool,
96    /// Language tag for spell-checking, e.g. "en-US".
97    pub lang: Option<String>,
98    /// Hyperlink attached to this text run.
99    pub hyperlink: Option<HyperlinkProps>,
100    /// Glow effect around the text run.
101    pub glow: Option<GlowProps>,
102    /// Outline (stroke) around the text run.
103    pub outline: Option<TextOutlineProps>,
104}
105
106/// A tab stop in a paragraph.
107#[derive(Debug, Clone)]
108pub struct TabStop {
109    /// Position in inches from left margin.
110    pub pos_inches: f64,
111    /// Alignment: "l" (left), "ctr" (center), "r" (right), "dec" (decimal).
112    pub align: String,
113}
114
115impl TabStop {
116    /// Create a tab stop at the given position with the given alignment.
117    ///
118    /// `align` is one of: `"l"` (left), `"ctr"` (center), `"r"` (right), `"dec"` (decimal).
119    pub fn new(pos_inches: f64, align: impl Into<String>) -> Self {
120        TabStop { pos_inches, align: align.into() }
121    }
122}
123
124/// Paragraph and layout options for a text box.
125#[derive(Debug, Clone)]
126pub struct TextOptions {
127    /// Position and size of the text box on the slide.
128    pub position: PositionProps,
129    /// Horizontal text alignment within the text box.
130    pub align: Option<AlignH>,
131    /// Vertical text alignment within the text box.
132    pub valign: Option<AlignV>,
133    /// Internal margin (inset) of the text box in inches.
134    pub margin: Option<Margin>,
135    /// Default font size in points for all runs that do not specify their own.
136    pub font_size: Option<f64>,
137    /// Default font face name for all runs that do not specify their own.
138    pub font_face: Option<String>,
139    /// Default font color as 6-digit hex, no `#` prefix.
140    pub color: Option<String>,
141    /// Default bold setting for all runs that do not specify their own.
142    pub bold: Option<bool>,
143    /// Default italic setting for all runs that do not specify their own.
144    pub italic: Option<bool>,
145    /// Whether text flows right-to-left.
146    pub rtl_mode: bool,
147    /// Line spacing in points (fixed spacing).
148    pub line_spacing: Option<f64>,
149    /// Line spacing as a multiple of normal line height, e.g. 1.5.
150    pub line_spacing_multiple: Option<f64>,
151    /// Space before each paragraph in points.
152    pub para_space_before: Option<f64>,
153    /// Space after each paragraph in points.
154    pub para_space_after: Option<f64>,
155    /// Bullet or numbering properties for paragraphs.
156    pub bullet: Option<BulletProps>,
157    /// Drop shadow effect on the text box.
158    pub shadow: Option<ShadowProps>,
159    /// Solid background fill color as 6-digit hex, no `#` prefix.
160    pub fill: Option<String>,
161    /// Gradient background fill for the text box.
162    pub gradient_fill: Option<GradientFill>,
163    /// Text auto-fit behavior for the text box.
164    pub fit: Option<TextFit>,
165    /// Whether text wraps within the text box.
166    pub wrap: Option<bool>,
167    /// Text direction: "horz", "vert", "vert270", "wordArtVert", "mongolianVert", "eaVert"
168    pub vert: Option<String>,
169    /// Paragraph indent level (0-based).
170    pub indent_level: Option<u32>,
171    /// Tab stops for paragraph layout.
172    pub tab_stops: Option<Vec<TabStop>>,
173    /// Internal body properties (computed from margin, valign, etc.).
174    pub body_prop: Option<BodyProp>,
175    /// Rotation in degrees (clockwise).
176    pub rotate: Option<f64>,
177    /// Flip horizontally.
178    pub flip_h: bool,
179    /// Flip vertically.
180    pub flip_v: bool,
181    /// Border line around the text box.
182    pub line: Option<ShapeLineProps>,
183    /// Number of text columns (≥ 2 to enable multi-column layout).
184    pub num_columns: Option<u32>,
185    /// Spacing between columns in inches (only used when num_columns ≥ 2).
186    pub column_spacing: Option<f64>,
187    /// Click-triggered animations on this text box (each fires on its own click).
188    /// Use `.animation()` builder method to append; supports multiple entries with
189    /// different `TextTarget` values to animate different character/paragraph ranges.
190    pub animations: Vec<AnimationEffect>,
191}
192
193/// Text auto-fit behavior for a text box.
194#[derive(Debug, Clone, PartialEq)]
195pub enum TextFit {
196    /// Do not auto-fit text.
197    None,
198    /// Shrink text font size to fit within the fixed text box.
199    Shrink,
200    /// Resize the text box to fit its content.
201    Resize,
202}
203
204/// Internal body properties for a text box, mapped to `<a:bodyPr>`.
205#[derive(Debug, Clone, Default)]
206pub struct BodyProp {
207    /// Whether text wraps inside the text box.
208    pub wrap: bool,
209    /// Left inset in EMUs.
210    pub l_ins: Option<i64>,
211    /// Top inset in EMUs.
212    pub t_ins: Option<i64>,
213    /// Right inset in EMUs.
214    pub r_ins: Option<i64>,
215    /// Bottom inset in EMUs.
216    pub b_ins: Option<i64>,
217    /// Vertical anchor: "t" (top), "ctr" (center), "b" (bottom).
218    pub anchor: Option<String>,
219    /// Text direction for the body, e.g. "vert", "vert270".
220    pub vert: Option<String>,
221    /// Whether auto-fit is enabled.
222    pub auto_fit: bool,
223}
224
225/// Bullet or numbering properties for a paragraph.
226#[derive(Debug, Clone)]
227pub struct BulletProps {
228    /// The type of bullet to render.
229    pub bullet_type: BulletType,
230    /// Unicode character code for custom bullet characters.
231    pub character_code: Option<String>,
232    /// Hanging indent in inches for bullet text.
233    pub indent: Option<f64>,
234    /// Starting number for numbered bullets.
235    pub number_start_at: Option<u32>,
236    /// Numbering style, e.g. "arabicPeriod", "romanUcPeriod".
237    pub style: Option<String>,
238}
239
240/// The type of bullet used in a paragraph.
241#[derive(Debug, Clone, PartialEq)]
242pub enum BulletType {
243    /// Standard filled-circle bullet.
244    Default,
245    /// Auto-incrementing numbered bullet.
246    Numbered,
247    /// Custom Unicode character bullet.
248    Character,
249}
250
251impl Default for TextOptions {
252    fn default() -> Self {
253        TextOptions {
254            position: PositionProps::default(),
255            align: None,
256            valign: None,
257            margin: None,
258            font_size: None,
259            font_face: None,
260            color: None,
261            bold: None,
262            italic: None,
263            rtl_mode: false,
264            line_spacing: None,
265            line_spacing_multiple: None,
266            para_space_before: None,
267            para_space_after: None,
268            bullet: None,
269            shadow: None,
270            fill: None,
271            gradient_fill: None,
272            fit: None,
273            wrap: Some(true),
274            vert: None,
275            indent_level: None,
276            tab_stops: None,
277            body_prop: None,
278            rotate: None,
279            flip_h: false,
280            flip_v: false,
281            line: None,
282            num_columns: None,
283            column_spacing: None,
284            animations: Vec::new(),
285        }
286    }
287}
288
289/// Builder for a single `TextRun` (rich-text character run).
290///
291/// ```rust,no_run
292/// use deckmint::objects::text::TextRunBuilder;
293/// let run = TextRunBuilder::new("Hello").color("FF0000").bold().font_size(24.0).build();
294/// ```
295pub struct TextRunBuilder {
296    run: TextRun,
297}
298
299impl TextRunBuilder {
300    /// Create a new text run builder with the given text content.
301    pub fn new(text: impl Into<String>) -> Self {
302        TextRunBuilder { run: TextRun::new(text) }
303    }
304
305    /// Create a text run builder for a LaTeX equation.
306    ///
307    /// The equation is converted to native OMML (editable in PowerPoint).
308    /// Requires the `math` feature.
309    ///
310    /// ```rust,no_run
311    /// use deckmint::TextRunBuilder;
312    /// let run = TextRunBuilder::equation(r"\frac{a}{b}").unwrap().build();
313    /// ```
314    #[cfg(feature = "math")]
315    pub fn equation(latex: &str) -> Result<Self, crate::error::PptxError> {
316        let run = TextRun::equation(latex)?;
317        Ok(TextRunBuilder { run })
318    }
319
320    /// Set the font color as 6-digit hex, no `#` prefix.
321    pub fn color(mut self, c: impl Into<String>) -> Self {
322        self.run.options.color = Some(c.into().trim_start_matches('#').to_uppercase());
323        self
324    }
325    /// Enable bold formatting.
326    pub fn bold(mut self) -> Self { self.run.options.bold = Some(true); self }
327    /// Enable italic formatting.
328    pub fn italic(mut self) -> Self { self.run.options.italic = Some(true); self }
329    /// Set the font size in points.
330    pub fn font_size(mut self, pt: f64) -> Self { self.run.options.font_size = Some(pt); self }
331    /// Set the font face name, e.g. "Arial".
332    pub fn font_face(mut self, f: impl Into<String>) -> Self { self.run.options.font_face = Some(f.into()); self }
333    /// Set the underline style, e.g. "sng" for single underline.
334    pub fn underline(mut self, style: impl Into<String>) -> Self { self.run.options.underline = Some(style.into()); self }
335    /// Set the underline color as 6-digit hex, no `#` prefix.
336    pub fn underline_color(mut self, c: impl Into<String>) -> Self { self.run.options.underline_color = Some(c.into().trim_start_matches('#').to_uppercase()); self }
337    /// Apply single strikethrough.
338    pub fn strike(mut self) -> Self { self.run.options.strike = Some("sng".to_string()); self }
339    /// Apply double strikethrough.
340    pub fn strike_double(mut self) -> Self { self.run.options.strike = Some("dbl".to_string()); self }
341    /// Set text transparency, 0.0–100.0 (0 = opaque, 100 = fully transparent).
342    pub fn transparency(mut self, t: f64) -> Self { self.run.options.transparency = Some(t); self }
343    /// Format this run as superscript.
344    pub fn superscript(mut self) -> Self { self.run.options.superscript = true; self }
345    /// Format this run as subscript.
346    pub fn subscript(mut self) -> Self { self.run.options.subscript = true; self }
347    /// Set the highlight (background) color as 6-digit hex, no `#` prefix.
348    pub fn highlight(mut self, c: impl Into<String>) -> Self { self.run.options.highlight = Some(c.into().trim_start_matches('#').to_uppercase()); self }
349    /// Set character spacing in points.
350    pub fn char_spacing(mut self, v: f64) -> Self { self.run.options.char_spacing = Some(v); self }
351    /// Set the language tag for spell-checking, e.g. "en-US".
352    pub fn lang(mut self, l: impl Into<String>) -> Self { self.run.options.lang = Some(l.into()); self }
353    /// End this run with a paragraph break (starts a new `<a:p>`).
354    pub fn break_line(mut self) -> Self { self.run.break_line = true; self }
355    /// Insert a soft line break (`<a:br>`) before this run (same paragraph).
356    pub fn soft_break_before(mut self) -> Self { self.run.soft_break_before = true; self }
357    /// Attach a hyperlink to this text run.
358    pub fn hyperlink(mut self, hl: HyperlinkProps) -> Self { self.run.options.hyperlink = Some(hl); self }
359    /// Apply a glow effect around this text run.
360    pub fn glow(mut self, g: GlowProps) -> Self { self.run.options.glow = Some(g); self }
361    /// Apply an outline (stroke) around this text run.
362    pub fn outline(mut self, o: TextOutlineProps) -> Self { self.run.options.outline = Some(o); self }
363    /// Make this run an auto-updating field (slide number, date, etc.) instead of static text.
364    pub fn field(mut self, ft: FieldType) -> Self { self.run.field = Some(ft); self }
365
366    /// Consume the builder and return the finished text run.
367    pub fn build(self) -> TextRun {
368        self.run
369    }
370}
371
372/// Builder for paragraph and layout options of a text box.
373pub struct TextOptionsBuilder {
374    opts: TextOptions,
375}
376
377impl TextOptionsBuilder {
378    /// Create a new text options builder with default values.
379    pub fn new() -> Self {
380        TextOptionsBuilder { opts: TextOptions::default() }
381    }
382
383    /// Set the X position in inches.
384    pub fn x(mut self, v: f64) -> Self { self.opts.position.x = Some(Coord::Inches(v)); self }
385    /// Set the Y position in inches.
386    pub fn y(mut self, v: f64) -> Self { self.opts.position.y = Some(Coord::Inches(v)); self }
387    /// Set the width in inches.
388    pub fn w(mut self, v: f64) -> Self { self.opts.position.w = Some(Coord::Inches(v)); self }
389    /// Set the height in inches.
390    pub fn h(mut self, v: f64) -> Self { self.opts.position.h = Some(Coord::Inches(v)); self }
391    /// Set position (x, y) in inches.
392    pub fn pos(self, x: f64, y: f64) -> Self {
393        self.x(x).y(y)
394    }
395    /// Set size (width, height) in inches.
396    pub fn size(self, w: f64, h: f64) -> Self {
397        self.w(w).h(h)
398    }
399    /// Set position and size from a [`CellRect`](crate::layout::CellRect).
400    pub fn rect(self, r: crate::layout::CellRect) -> Self {
401        self.pos(r.x, r.y).size(r.w, r.h)
402    }
403    /// Set position (x, y) and size (w, h) in inches in a single call.
404    pub fn bounds(self, x: f64, y: f64, w: f64, h: f64) -> Self {
405        self.pos(x, y).size(w, h)
406    }
407    /// Set the X position as a percentage of slide width.
408    pub fn x_pct(mut self, v: f64) -> Self { self.opts.position.x = Some(Coord::Percent(v)); self }
409    /// Set the Y position as a percentage of slide height.
410    pub fn y_pct(mut self, v: f64) -> Self { self.opts.position.y = Some(Coord::Percent(v)); self }
411    /// Set the width as a percentage of slide width.
412    pub fn w_pct(mut self, v: f64) -> Self { self.opts.position.w = Some(Coord::Percent(v)); self }
413    /// Set the height as a percentage of slide height.
414    pub fn h_pct(mut self, v: f64) -> Self { self.opts.position.h = Some(Coord::Percent(v)); self }
415    /// Set the default font size in points.
416    pub fn font_size(mut self, pt: f64) -> Self { self.opts.font_size = Some(pt); self }
417    /// Set the default font face name, e.g. "Arial".
418    pub fn font_face(mut self, f: impl Into<String>) -> Self { self.opts.font_face = Some(f.into()); self }
419    /// Set the default font color as hex (e.g. `"#4472C4"` or `"4472C4"`).
420    pub fn color(mut self, c: impl Into<String>) -> Self { self.opts.color = Some(c.into().trim_start_matches('#').to_uppercase()); self }
421    /// Enable bold formatting as the default for all runs.
422    pub fn bold(mut self) -> Self { self.opts.bold = Some(true); self }
423    /// Enable italic formatting as the default for all runs.
424    pub fn italic(mut self) -> Self { self.opts.italic = Some(true); self }
425    /// Set horizontal text alignment.
426    pub fn align(mut self, a: AlignH) -> Self { self.opts.align = Some(a); self }
427    /// Set vertical text alignment.
428    pub fn valign(mut self, a: AlignV) -> Self { self.opts.valign = Some(a); self }
429    /// Enable right-to-left text direction.
430    pub fn rtl(mut self) -> Self { self.opts.rtl_mode = true; self }
431    /// Set the solid background fill color as hex (e.g. `"#4472C4"` or `"4472C4"`).
432    pub fn fill(mut self, c: impl Into<String>) -> Self { self.opts.fill = Some(c.into().trim_start_matches('#').to_uppercase()); self }
433    /// Set a gradient background fill for the text box.
434    pub fn gradient_fill(mut self, g: GradientFill) -> Self { self.opts.gradient_fill = Some(g); self }
435    /// Set the internal margin (inset) of the text box.
436    pub fn margin(mut self, m: impl Into<Margin>) -> Self { self.opts.margin = Some(m.into()); self }
437    /// Apply a drop shadow effect to the text box.
438    pub fn shadow(mut self, s: ShadowProps) -> Self { self.opts.shadow = Some(s); self }
439    /// Set fixed line spacing in points.
440    pub fn line_spacing(mut self, v: f64) -> Self { self.opts.line_spacing = Some(v); self }
441    /// Set line spacing as a multiple (e.g. `1.5` for 150% line spacing).
442    pub fn line_spacing_multiple(mut self, mult: f64) -> Self { self.opts.line_spacing_multiple = Some(mult); self }
443    /// Set the paragraph space before in points.
444    pub fn para_space_before(mut self, pt: f64) -> Self { self.opts.para_space_before = Some(pt); self }
445    /// Set the paragraph space after in points.
446    pub fn para_space_after(mut self, pt: f64) -> Self { self.opts.para_space_after = Some(pt); self }
447    /// Set the paragraph indent level (0-based).
448    pub fn indent_level(mut self, level: u32) -> Self { self.opts.indent_level = Some(level); self }
449    /// Set the text auto-fit behavior.
450    pub fn fit(mut self, f: TextFit) -> Self { self.opts.fit = Some(f); self }
451    /// Resize the text box to fit its content (`<a:spAutoFit/>`).
452    pub fn autofit(mut self) -> Self { self.opts.fit = Some(TextFit::Resize); self }
453    /// Shrink text to fit the fixed text box (`<a:normAutofit/>`).
454    pub fn shrink_text(mut self) -> Self { self.opts.fit = Some(TextFit::Shrink); self }
455    /// Set text direction. Values: "horz" (default), "vert", "vert270", "wordArtVert",
456    /// "mongolianVert", "eaVert"
457    pub fn text_direction(mut self, v: impl Into<String>) -> Self { self.opts.vert = Some(v.into()); self }
458    /// Set tab stops for this text box.
459    pub fn tab_stops(mut self, stops: Vec<TabStop>) -> Self { self.opts.tab_stops = Some(stops); self }
460    /// Set rotation in degrees (clockwise).
461    pub fn rotate(mut self, deg: f64) -> Self { self.opts.rotate = Some(deg); self }
462    /// Flip the text box horizontally.
463    pub fn flip_h(mut self) -> Self { self.opts.flip_h = true; self }
464    /// Flip the text box vertically.
465    pub fn flip_v(mut self) -> Self { self.opts.flip_v = true; self }
466    /// Add a border line around the text box.
467    pub fn line(mut self, l: ShapeLineProps) -> Self { self.opts.line = Some(l); self }
468    /// Set the border color as 6-digit hex, no `#` prefix, creating a line if needed.
469    pub fn line_color(mut self, color: impl Into<String>) -> Self {
470        let line = self.opts.line.get_or_insert_with(ShapeLineProps::default);
471        line.color = Some(color.into().trim_start_matches('#').to_uppercase());
472        self
473    }
474    /// Set the border width in points, creating a line if needed.
475    pub fn line_width(mut self, pt: f64) -> Self {
476        let line = self.opts.line.get_or_insert_with(ShapeLineProps::default);
477        line.width = Some(pt);
478        self
479    }
480    /// Flow text across `n` columns.
481    pub fn columns(mut self, n: u32) -> Self { self.opts.num_columns = Some(n); self }
482    /// Gap between columns in inches (only meaningful when columns ≥ 2).
483    pub fn column_spacing(mut self, inches: f64) -> Self { self.opts.column_spacing = Some(inches); self }
484    /// Append a click-triggered animation effect to this text box.
485    pub fn animation(mut self, anim: AnimationEffect) -> Self { self.opts.animations.push(anim); self }
486
487    /// Attach a colour-emphasis animation that targets the character range covered by
488    /// run `run_idx` (0-based index into the `runs` slice you will pass to `add_text_runs`).
489    /// Character offsets are computed automatically from the run text lengths.
490    ///
491    /// ```rust,no_run
492    /// # use deckmint::objects::text::{TextOptionsBuilder, TextRunBuilder};
493    /// # use deckmint::types::AnimationEffect;
494    /// let runs = vec![
495    ///     TextRunBuilder::new("First sentence.").font_size(24.0).build(),
496    ///     TextRunBuilder::new(" Second sentence.").font_size(24.0).build(),
497    /// ];
498    /// let opts = TextOptionsBuilder::new().x(1.0).y(2.0).w(8.0).h(1.0)
499    ///     .animation_on_run(AnimationEffect::font_color("FF0000"), &runs, 0)
500    ///     .animation_on_run(AnimationEffect::font_color("0000FF"), &runs, 1)
501    ///     .build();
502    /// ```
503    pub fn animation_on_run(mut self, anim: AnimationEffect, runs: &[TextRun], run_idx: usize) -> Self {
504        let (st, end) = char_range_for_run(runs, run_idx);
505        self.opts.animations.push(anim.with_char_range(st, end));
506        self
507    }
508
509    /// Consume the builder and return the finished text options.
510    pub fn build(self) -> TextOptions {
511        self.opts
512    }
513}
514
515impl Default for TextOptionsBuilder {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521/// Returns the `(st, end)` character-index range (both inclusive, 0-based) that corresponds
522/// to the text of `runs[run_idx]` within the full concatenated paragraph string.
523///
524/// Use this with `AnimationEffect::font_color(…).with_char_range(st, end)` to target a
525/// specific run inside a `add_text_runs` paragraph, or use the convenience method
526/// `TextOptionsBuilder::animation_on_run(anim, runs, run_idx)` which calls this internally.
527///
528/// Panics if `run_idx >= runs.len()` or if the run has zero characters.
529pub fn char_range_for_run(runs: &[TextRun], run_idx: usize) -> (u32, u32) {
530    let st: usize = runs[..run_idx].iter().map(|r| r.text.chars().count()).sum();
531    let run_len = runs[run_idx].text.chars().count();
532    assert!(run_len > 0, "char_range_for_run: run {run_idx} has zero characters");
533    let end = st + run_len - 1;
534    (st as u32, end as u32)
535}