Skip to main content

ooxml_omml/
ext.rs

1//! Extension traits for OMML (Office Math Markup Language) types.
2//!
3//! Provides convenient accessor and inspection methods for the math types
4//! defined in [`crate::math`].
5//!
6//! # Design
7//!
8//! Extension traits are defined here rather than as inherent methods to keep
9//! the generated/parsed types simple and allow consumers to opt in selectively.
10//!
11//! ECMA-376 Part 4, Section 22 defines the OMML schema.
12
13use crate::math::{
14    Fraction, FractionType, LimitLocation, MathElement, MathZone, Matrix, Nary, Radical,
15};
16
17// =============================================================================
18// MathZoneExt
19// =============================================================================
20
21/// Extension methods for [`MathZone`] (m:oMath).
22pub trait MathZoneExt {
23    /// Get the number of top-level elements in this math zone.
24    fn element_count(&self) -> usize;
25
26    /// Check if the math zone contains no elements.
27    fn is_empty(&self) -> bool;
28
29    /// Check if any top-level element is a fraction.
30    fn has_fractions(&self) -> bool;
31
32    /// Check if any top-level element is a radical (square root or nth root).
33    fn has_radicals(&self) -> bool;
34
35    /// Check if any top-level element is a matrix.
36    fn has_matrices(&self) -> bool;
37
38    /// Check if any top-level element is a script (subscript or superscript).
39    fn has_scripts(&self) -> bool;
40
41    /// Check if any top-level element is an n-ary operator (sum, integral, etc.).
42    fn has_nary(&self) -> bool;
43}
44
45impl MathZoneExt for MathZone {
46    fn element_count(&self) -> usize {
47        self.elements.len()
48    }
49
50    fn is_empty(&self) -> bool {
51        self.elements.is_empty()
52    }
53
54    fn has_fractions(&self) -> bool {
55        self.elements.iter().any(|e| e.is_fraction())
56    }
57
58    fn has_radicals(&self) -> bool {
59        self.elements.iter().any(|e| e.is_radical())
60    }
61
62    fn has_matrices(&self) -> bool {
63        self.elements.iter().any(|e| e.is_matrix())
64    }
65
66    fn has_scripts(&self) -> bool {
67        self.elements.iter().any(|e| e.is_script())
68    }
69
70    fn has_nary(&self) -> bool {
71        self.elements.iter().any(|e| e.is_nary())
72    }
73}
74
75// =============================================================================
76// MathElementExt
77// =============================================================================
78
79/// Extension methods for [`MathElement`].
80pub trait MathElementExt {
81    /// Check if this is a fraction element.
82    fn is_fraction(&self) -> bool;
83    /// Check if this is a radical (root) element.
84    fn is_radical(&self) -> bool;
85    /// Check if this is an n-ary operator (sum, integral, product, etc.).
86    fn is_nary(&self) -> bool;
87    /// Check if this is any kind of script (sub, sup, sub+sup, or pre-script).
88    fn is_script(&self) -> bool;
89    /// Check if this is a matrix.
90    fn is_matrix(&self) -> bool;
91    /// Check if this is a delimiter (parentheses, brackets, etc.).
92    fn is_delimiter(&self) -> bool;
93    /// Check if this is a text run.
94    fn is_run(&self) -> bool;
95
96    /// Try to downcast to a [`Fraction`].
97    fn as_fraction(&self) -> Option<&Fraction>;
98    /// Try to downcast to a [`Radical`].
99    fn as_radical(&self) -> Option<&Radical>;
100    /// Try to downcast to an [`Nary`].
101    fn as_nary(&self) -> Option<&Nary>;
102    /// Try to downcast to a [`Matrix`].
103    fn as_matrix(&self) -> Option<&Matrix>;
104
105    /// Count the number of operator elements (Nary, Fraction, Radical) in this
106    /// element recursively.
107    fn operator_count(&self) -> usize;
108}
109
110impl MathElementExt for MathElement {
111    fn is_fraction(&self) -> bool {
112        matches!(self, MathElement::Fraction(_))
113    }
114
115    fn is_radical(&self) -> bool {
116        matches!(self, MathElement::Radical(_))
117    }
118
119    fn is_nary(&self) -> bool {
120        matches!(self, MathElement::Nary(_))
121    }
122
123    fn is_script(&self) -> bool {
124        matches!(
125            self,
126            MathElement::Subscript(_)
127                | MathElement::Superscript(_)
128                | MathElement::SubSuperscript(_)
129                | MathElement::PreScript(_)
130        )
131    }
132
133    fn is_matrix(&self) -> bool {
134        matches!(self, MathElement::Matrix(_))
135    }
136
137    fn is_delimiter(&self) -> bool {
138        matches!(self, MathElement::Delimiter(_))
139    }
140
141    fn is_run(&self) -> bool {
142        matches!(self, MathElement::Run(_))
143    }
144
145    fn as_fraction(&self) -> Option<&Fraction> {
146        if let MathElement::Fraction(f) = self {
147            Some(f)
148        } else {
149            None
150        }
151    }
152
153    fn as_radical(&self) -> Option<&Radical> {
154        if let MathElement::Radical(r) = self {
155            Some(r)
156        } else {
157            None
158        }
159    }
160
161    fn as_nary(&self) -> Option<&Nary> {
162        if let MathElement::Nary(n) = self {
163            Some(n)
164        } else {
165            None
166        }
167    }
168
169    fn as_matrix(&self) -> Option<&Matrix> {
170        if let MathElement::Matrix(m) = self {
171            Some(m)
172        } else {
173            None
174        }
175    }
176
177    fn operator_count(&self) -> usize {
178        count_operators_in_zone_list(std::slice::from_ref(self))
179    }
180}
181
182/// Count operator elements (Nary, Fraction, Radical) recursively across a
183/// slice of `MathElement`.
184fn count_operators_in_zone_list(elements: &[MathElement]) -> usize {
185    elements.iter().map(count_operators).sum()
186}
187
188fn count_operators(e: &MathElement) -> usize {
189    match e {
190        MathElement::Fraction(f) => {
191            1 + count_operators_in_zone(&f.numerator) + count_operators_in_zone(&f.denominator)
192        }
193        MathElement::Radical(r) => {
194            1 + count_operators_in_zone(&r.base) + count_operators_in_zone(&r.degree)
195        }
196        MathElement::Nary(n) => {
197            1 + count_operators_in_zone(&n.subscript)
198                + count_operators_in_zone(&n.superscript)
199                + count_operators_in_zone(&n.base)
200        }
201        MathElement::Subscript(s) | MathElement::Superscript(s) => {
202            count_operators_in_zone(&s.base) + count_operators_in_zone(&s.script)
203        }
204        MathElement::SubSuperscript(s) => {
205            count_operators_in_zone(&s.base)
206                + count_operators_in_zone(&s.subscript)
207                + count_operators_in_zone(&s.superscript)
208        }
209        MathElement::PreScript(p) => {
210            count_operators_in_zone(&p.base)
211                + count_operators_in_zone(&p.subscript)
212                + count_operators_in_zone(&p.superscript)
213        }
214        MathElement::Delimiter(d) => d.elements.iter().map(count_operators_in_zone).sum(),
215        MathElement::Matrix(m) => m
216            .rows
217            .iter()
218            .flat_map(|row| row.iter())
219            .map(count_operators_in_zone)
220            .sum(),
221        MathElement::Function(f) => {
222            count_operators_in_zone(&f.name) + count_operators_in_zone(&f.argument)
223        }
224        MathElement::Accent(a) => count_operators_in_zone(&a.base),
225        MathElement::Bar(b) => count_operators_in_zone(&b.base),
226        MathElement::Box(b) => count_operators_in_zone(&b.content),
227        MathElement::BorderBox(b) => count_operators_in_zone(&b.content),
228        MathElement::EquationArray(e) => e.equations.iter().map(count_operators_in_zone).sum(),
229        MathElement::LowerLimit(l) | MathElement::UpperLimit(l) => {
230            count_operators_in_zone(&l.base) + count_operators_in_zone(&l.limit)
231        }
232        MathElement::GroupChar(g) => count_operators_in_zone(&g.base),
233        MathElement::Phantom(p) => count_operators_in_zone(&p.content),
234        MathElement::Run(_) => 0,
235    }
236}
237
238fn count_operators_in_zone(zone: &MathZone) -> usize {
239    count_operators_in_zone_list(&zone.elements)
240}
241
242// =============================================================================
243// FractionExt
244// =============================================================================
245
246/// Extension methods for [`Fraction`] (m:f).
247pub trait FractionExt {
248    /// Get the fraction type (bar, skewed, linear, no-bar).
249    fn fraction_type(&self) -> Option<FractionType>;
250    /// Get the numerator zone.
251    fn numerator(&self) -> &MathZone;
252    /// Get the denominator zone.
253    fn denominator(&self) -> &MathZone;
254    /// Check if this is a skewed (diagonal) fraction.
255    fn is_skewed(&self) -> bool;
256}
257
258impl FractionExt for Fraction {
259    fn fraction_type(&self) -> Option<FractionType> {
260        self.fraction_type
261    }
262
263    fn numerator(&self) -> &MathZone {
264        &self.numerator
265    }
266
267    fn denominator(&self) -> &MathZone {
268        &self.denominator
269    }
270
271    fn is_skewed(&self) -> bool {
272        self.fraction_type == Some(FractionType::Skewed)
273    }
274}
275
276// =============================================================================
277// NaryExt
278// =============================================================================
279
280/// Extension methods for [`Nary`] (m:nary — summation, integral, product, etc.).
281pub trait NaryExt {
282    /// Get the lower limit (subscript) zone.
283    fn lower_limit(&self) -> &MathZone;
284    /// Get the upper limit (superscript) zone.
285    fn upper_limit(&self) -> &MathZone;
286    /// Get the limit location (under/over the operator vs. sub/superscript).
287    fn limit_location(&self) -> Option<LimitLocation>;
288}
289
290impl NaryExt for Nary {
291    fn lower_limit(&self) -> &MathZone {
292        &self.subscript
293    }
294
295    fn upper_limit(&self) -> &MathZone {
296        &self.superscript
297    }
298
299    fn limit_location(&self) -> Option<LimitLocation> {
300        self.limit_location
301    }
302}
303
304// =============================================================================
305// RadicalExt
306// =============================================================================
307
308/// Extension methods for [`Radical`] (m:rad).
309pub trait RadicalExt {
310    /// Get the radicand (the expression under the radical sign).
311    fn radicand(&self) -> &MathZone;
312    /// Get the degree zone (for nth roots; empty for square roots).
313    fn degree(&self) -> &MathZone;
314    /// Check if this is a square root (degree is empty / hidden).
315    fn is_square_root(&self) -> bool;
316}
317
318impl RadicalExt for Radical {
319    fn radicand(&self) -> &MathZone {
320        &self.base
321    }
322
323    fn degree(&self) -> &MathZone {
324        &self.degree
325    }
326
327    fn is_square_root(&self) -> bool {
328        self.degree.elements.is_empty() || self.hide_degree
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::math::{Delimiter, Fraction, FractionType, MathRun, Nary, Radical, Script};
336
337    fn run(text: &str) -> MathElement {
338        MathElement::Run(MathRun {
339            text: text.to_string(),
340            properties: None,
341        })
342    }
343
344    fn zone(elements: Vec<MathElement>) -> MathZone {
345        MathZone { elements }
346    }
347
348    // -------------------------------------------------------------------------
349    // MathZoneExt tests
350    // -------------------------------------------------------------------------
351
352    #[test]
353    fn test_math_zone_empty() {
354        let z = MathZone::default();
355        assert!(z.is_empty());
356        assert_eq!(z.element_count(), 0);
357        assert!(!z.has_fractions());
358        assert!(!z.has_radicals());
359        assert!(!z.has_nary());
360    }
361
362    #[test]
363    fn test_math_zone_has_fraction() {
364        let z = zone(vec![MathElement::Fraction(Fraction::default())]);
365        assert!(!z.is_empty());
366        assert_eq!(z.element_count(), 1);
367        assert!(z.has_fractions());
368        assert!(!z.has_radicals());
369    }
370
371    #[test]
372    fn test_math_zone_has_radical() {
373        let z = zone(vec![MathElement::Radical(Radical::default())]);
374        assert!(z.has_radicals());
375        assert!(!z.has_fractions());
376    }
377
378    #[test]
379    fn test_math_zone_has_nary() {
380        let z = zone(vec![MathElement::Nary(Nary::default())]);
381        assert!(z.has_nary());
382        assert!(!z.has_matrices());
383    }
384
385    // -------------------------------------------------------------------------
386    // MathElementExt tests
387    // -------------------------------------------------------------------------
388
389    #[test]
390    fn test_element_type_checks() {
391        let frac = MathElement::Fraction(Fraction::default());
392        assert!(frac.is_fraction());
393        assert!(!frac.is_radical());
394        assert!(!frac.is_run());
395        assert!(frac.as_fraction().is_some());
396        assert!(frac.as_radical().is_none());
397
398        let r = run("x");
399        assert!(r.is_run());
400        assert!(!r.is_fraction());
401        assert!(r.as_fraction().is_none());
402    }
403
404    #[test]
405    fn test_script_check() {
406        let sub = MathElement::Subscript(Script::default());
407        let sup = MathElement::Superscript(Script::default());
408        assert!(sub.is_script());
409        assert!(sup.is_script());
410        assert!(!sub.is_fraction());
411    }
412
413    #[test]
414    fn test_delimiter_check() {
415        let d = MathElement::Delimiter(Delimiter::default());
416        assert!(d.is_delimiter());
417        assert!(!d.is_run());
418    }
419
420    #[test]
421    fn test_operator_count_nested() {
422        // fraction containing a radical in the numerator
423        let frac = MathElement::Fraction(Fraction {
424            numerator: zone(vec![MathElement::Radical(Radical::default())]),
425            denominator: zone(vec![run("2")]),
426            fraction_type: None,
427        });
428        // 1 (fraction) + 1 (radical) = 2
429        assert_eq!(frac.operator_count(), 2);
430    }
431
432    #[test]
433    fn test_operator_count_run() {
434        assert_eq!(run("x").operator_count(), 0);
435    }
436
437    // -------------------------------------------------------------------------
438    // FractionExt tests
439    // -------------------------------------------------------------------------
440
441    #[test]
442    fn test_fraction_ext() {
443        let f = Fraction {
444            numerator: zone(vec![run("1")]),
445            denominator: zone(vec![run("2")]),
446            fraction_type: Some(FractionType::Skewed),
447        };
448        assert!(f.is_skewed());
449        assert_eq!(f.fraction_type(), Some(FractionType::Skewed));
450        assert_eq!(f.numerator().elements.len(), 1);
451        assert_eq!(f.denominator().elements.len(), 1);
452    }
453
454    #[test]
455    fn test_fraction_not_skewed() {
456        let f = Fraction {
457            fraction_type: Some(FractionType::Bar),
458            ..Default::default()
459        };
460        assert!(!f.is_skewed());
461    }
462
463    // -------------------------------------------------------------------------
464    // RadicalExt tests
465    // -------------------------------------------------------------------------
466
467    #[test]
468    fn test_radical_square_root() {
469        let r = Radical::default(); // degree is empty
470        assert!(r.is_square_root());
471        assert_eq!(r.degree().elements.len(), 0);
472    }
473
474    #[test]
475    fn test_radical_nth_root() {
476        let r = Radical {
477            degree: zone(vec![run("3")]),
478            base: zone(vec![run("x")]),
479            hide_degree: false,
480        };
481        assert!(!r.is_square_root());
482        assert_eq!(r.degree().elements.len(), 1);
483        assert_eq!(r.radicand().elements.len(), 1);
484    }
485
486    #[test]
487    fn test_radical_hide_degree_treated_as_square() {
488        let r = Radical {
489            degree: zone(vec![run("2")]),
490            base: zone(vec![run("x")]),
491            hide_degree: true,
492        };
493        // hide_degree=true → treated as square root regardless of degree content
494        assert!(r.is_square_root());
495    }
496
497    // -------------------------------------------------------------------------
498    // NaryExt tests
499    // -------------------------------------------------------------------------
500
501    #[test]
502    fn test_nary_ext() {
503        let n = Nary {
504            operator: Some("∑".to_string()),
505            subscript: zone(vec![run("i=0")]),
506            superscript: zone(vec![run("n")]),
507            base: zone(vec![run("x")]),
508            limit_location: Some(LimitLocation::UnderOver),
509            grow: false,
510        };
511        assert_eq!(n.lower_limit().elements.len(), 1);
512        assert_eq!(n.upper_limit().elements.len(), 1);
513        assert_eq!(n.limit_location(), Some(LimitLocation::UnderOver));
514    }
515}