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}