1#![warn(missing_docs)]
11
12use std::ops::Range;
13use std::sync::Arc;
14
15use crate::text::metrics as text_metrics;
16use crate::tree::{Color, FontFamily, FontWeight, Rect, TextWrap};
17
18const DEFAULT_RULE_THICKNESS: f32 = 1.1;
19const SCRIPT_SCALE: f32 = 0.72;
20const LARGE_OPERATOR_SCALE: f32 = 1.35;
21const FRACTION_PAD_EM: f32 = 0.18;
22const FRACTION_GAP_EM: f32 = 0.18;
23const SQRT_GAP_EM: f32 = 0.10;
24const TABLE_COL_GAP_EM: f32 = 0.8;
25const TABLE_ROW_GAP_EM: f32 = 0.35;
26const CASES_COL_GAP_EM: f32 = 0.5;
27const RADICAL_GLYPH: char = '√';
28const THIN_MATH_SPACE_EM: f32 = 0.08;
29const MEDIUM_MATH_SPACE_EM: f32 = 0.18;
30const THICK_MATH_SPACE_EM: f32 = 0.28;
31#[cfg(feature = "symbols")]
32const STRETCHY_VARIANT_CHARS: [char; 29] = [
33 '(',
34 ')',
35 '[',
36 ']',
37 '{',
38 '}',
39 '|',
40 '‖',
41 '⌊',
42 '⌋',
43 '⌈',
44 '⌉',
45 RADICAL_GLYPH,
46 '∑',
47 '∫',
48 '∏',
49 '⋂',
50 '⋃',
51 '∐',
52 '∮',
53 '∬',
54 '∭',
55 '⨁',
56 '⨂',
57 '⨀',
58 '⋁',
59 '⋀',
60 '⨄',
61 '⨆',
62];
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
72pub enum MathDisplay {
73 #[default]
75 Inline,
76 Block,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
83pub enum MathColumnAlignment {
84 Left,
86 #[default]
88 Center,
89 Right,
91}
92
93#[derive(Clone, Debug, PartialEq)]
100#[non_exhaustive]
101pub enum MathExpr {
102 Row(Vec<MathExpr>),
104 Identifier(String),
106 Number(String),
108 Operator(String),
111 OperatorWithMetadata {
114 text: String,
116 lspace: Option<f32>,
118 rspace: Option<f32>,
120 large_operator: Option<bool>,
122 movable_limits: Option<bool>,
125 },
126 Text(String),
128 Space(f32),
130 Fraction {
132 numerator: Arc<MathExpr>,
134 denominator: Arc<MathExpr>,
136 },
137 Sqrt(Arc<MathExpr>),
139 Root {
141 base: Arc<MathExpr>,
143 index: Arc<MathExpr>,
145 },
146 Scripts {
149 base: Arc<MathExpr>,
151 sub: Option<Arc<MathExpr>>,
153 sup: Option<Arc<MathExpr>>,
155 },
156 UnderOver {
159 base: Arc<MathExpr>,
161 under: Option<Arc<MathExpr>>,
163 over: Option<Arc<MathExpr>>,
165 },
166 Accent {
168 base: Arc<MathExpr>,
170 accent: Arc<MathExpr>,
172 stretch: bool,
175 },
176 Fenced {
179 open: Option<String>,
181 close: Option<String>,
183 body: Arc<MathExpr>,
185 },
186 Table {
189 rows: Vec<Vec<MathExpr>>,
191 column_alignments: Vec<MathColumnAlignment>,
194 column_gap: Option<f32>,
197 row_gap: Option<f32>,
200 },
201 Source {
205 source: Range<usize>,
207 body: Arc<MathExpr>,
209 },
210 Error(String),
213}
214
215impl MathExpr {
216 pub fn row(children: impl IntoIterator<Item = MathExpr>) -> Self {
220 let mut children: Vec<MathExpr> = children.into_iter().collect();
221 match children.len() {
222 0 => MathExpr::Row(Vec::new()),
223 1 => children.pop().unwrap(),
224 _ => MathExpr::Row(children),
225 }
226 }
227
228 pub fn source_range(&self) -> Option<&Range<usize>> {
231 match self {
232 MathExpr::Source { source, .. } => Some(source),
233 _ => None,
234 }
235 }
236
237 pub fn without_source(&self) -> &MathExpr {
240 match self {
241 MathExpr::Source { body, .. } => body.without_source(),
242 _ => self,
243 }
244 }
245}
246
247#[derive(Clone, Debug, PartialEq)]
254pub struct MathLayout {
255 pub width: f32,
257 pub ascent: f32,
259 pub descent: f32,
261 pub atoms: Vec<MathAtom>,
264}
265
266impl MathLayout {
267 pub fn height(&self) -> f32 {
269 self.ascent + self.descent
270 }
271}
272
273#[derive(Clone, Debug, PartialEq)]
280pub enum MathAtom {
281 Glyph {
283 text: String,
285 x: f32,
287 y_baseline: f32,
290 size: f32,
292 weight: FontWeight,
294 italic: bool,
296 },
297 GlyphId {
302 glyph_id: u16,
304 rect: Rect,
306 view_box: Rect,
309 },
310 Rule {
312 rect: Rect,
314 },
315 Radical {
319 points: [[f32; 2]; 5],
322 thickness: f32,
324 },
325 Delimiter {
328 delimiter: String,
330 rect: Rect,
332 thickness: f32,
334 },
335}
336
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338enum MathOperatorClass {
339 Ordinary,
340 Binary,
341 Relation,
342 Large,
343 Punctuation,
344}
345
346#[derive(Clone, Copy, Debug, PartialEq)]
347struct MathOperatorInfo {
348 class: MathOperatorClass,
349 lspace_em: f32,
350 rspace_em: f32,
351 large_operator: bool,
352 movable_limits: bool,
353}
354
355impl MathOperatorInfo {
356 fn new(class: MathOperatorClass, lspace_em: f32, rspace_em: f32) -> Self {
357 Self {
358 class,
359 lspace_em,
360 rspace_em,
361 large_operator: false,
362 movable_limits: false,
363 }
364 }
365
366 fn large(mut self) -> Self {
367 self.large_operator = true;
368 self.movable_limits = true;
369 self
370 }
371
372 fn large_with_side_scripts(mut self) -> Self {
373 self.large_operator = true;
374 self.movable_limits = false;
375 self
376 }
377}
378
379fn operator_info(operator: &str) -> MathOperatorInfo {
380 use MathOperatorClass::*;
381 match operator {
382 "+" | "-" | "±" | "∓" | "·" | "×" | "÷" | "∪" | "∩" | "∧" | "∨" | "⊕" | "⊖" | "⊗" | "⊘"
383 | "⊙" | "⋆" | "∗" | "∘" | "•" | "⊔" | "⊓" | "⨿" | "≀" | "◁" | "▷" | "⋄" | "∖" => {
384 MathOperatorInfo::new(Binary, MEDIUM_MATH_SPACE_EM, MEDIUM_MATH_SPACE_EM)
385 }
386 "=" | "<" | ">" | "≤" | "≥" | "≠" | "≈" | "∼" | "→" | "←" | "↔" | "⇒" | "⇐" | "⇔" | "⟹"
387 | "⟸" | "⟺" | "⟶" | "⟵" | "⟷" | "↦" | "⟼" | "↪" | "↩" | "∈" | "∉" | "∋" | "∌" | "⊂"
388 | "⊃" | "⊆" | "⊇" | "⊊" | "⊋" | "≡" | "≃" | "≅" | "∝" | "≺" | "≻" | "⪯" | "⪰" | "≪"
389 | "≫" | "∥" | "⊥" | "≍" | "≐" | "⊨" | "⊢" | "⊣" | "∴" | "∵" => {
390 MathOperatorInfo::new(Relation, MEDIUM_MATH_SPACE_EM, MEDIUM_MATH_SPACE_EM)
391 }
392 "∑" | "∏" | "⋂" | "⋃" | "∐" | "⨁" | "⨂" | "⨀" | "⋁" | "⋀" | "⨄" | "⨆" => {
393 MathOperatorInfo::new(Large, THIN_MATH_SPACE_EM, THIN_MATH_SPACE_EM).large()
394 }
395 "∫" | "∮" | "∬" | "∭" => {
396 MathOperatorInfo::new(Large, THIN_MATH_SPACE_EM, THIN_MATH_SPACE_EM)
397 .large_with_side_scripts()
398 }
399 "," | "." | ";" | ":" => MathOperatorInfo::new(Punctuation, 0.0, THIN_MATH_SPACE_EM),
400 _ => MathOperatorInfo::new(Ordinary, 0.0, 0.0),
401 }
402}
403
404#[derive(Clone, Copy, Debug)]
405struct LayoutCtx {
406 size: f32,
407 display: MathDisplay,
408}
409
410impl LayoutCtx {
411 fn script(self) -> Self {
412 Self {
413 size: self.metrics().script_size(),
414 display: MathDisplay::Inline,
415 }
416 }
417
418 fn large_operator(self) -> Self {
419 Self {
420 size: self.metrics().large_operator_size(),
421 display: self.display,
422 }
423 }
424
425 fn metrics(self) -> MathMetrics {
426 MathMetrics {
427 size: self.size,
428 display: self.display,
429 }
430 }
431}
432
433#[derive(Clone, Copy, Debug)]
434struct MathMetrics {
435 size: f32,
436 display: MathDisplay,
437}
438
439impl MathMetrics {
440 fn font_constants(self) -> Option<OpenTypeMathConstants> {
441 open_type_math_constants()
442 }
443
444 fn script_size(self) -> f32 {
445 self.font_constants()
446 .and_then(|constants| constants.script_scale(self.size))
447 .unwrap_or(self.size * SCRIPT_SCALE)
448 .max(6.0)
449 }
450
451 fn large_operator_size(self) -> f32 {
452 self.size * LARGE_OPERATOR_SCALE
453 }
454
455 fn rule_thickness(self) -> f32 {
456 self.font_constants()
457 .and_then(|constants| constants.fraction_rule_thickness(self.size))
458 .unwrap_or(DEFAULT_RULE_THICKNESS * self.size / 16.0)
459 .max(0.75)
460 }
461
462 fn radical_rule_thickness(self) -> f32 {
463 self.font_constants()
464 .and_then(|constants| constants.radical_rule_thickness(self.size))
465 .unwrap_or_else(|| self.rule_thickness())
466 .max(0.75)
467 }
468
469 fn default_ascent(self) -> f32 {
470 self.size * 0.75
471 }
472
473 fn default_descent(self) -> f32 {
474 self.size * 0.25
475 }
476
477 fn glyph_ascent(self) -> f32 {
478 self.size * 0.82
479 }
480
481 fn glyph_descent(self) -> f32 {
482 self.size * 0.22
483 }
484
485 fn space_width(self, em: f32) -> f32 {
486 self.size * em
487 }
488
489 fn operator_spacing_with_overrides(
490 self,
491 operator: &str,
492 lspace_em: Option<f32>,
493 rspace_em: Option<f32>,
494 ) -> (f32, f32) {
495 let info = operator_info(operator);
496 (
497 self.size * lspace_em.unwrap_or(info.lspace_em),
498 self.size * rspace_em.unwrap_or(info.rspace_em),
499 )
500 }
501
502 fn fraction_pad(self) -> f32 {
503 self.size
504 * if matches!(self.display, MathDisplay::Block) {
505 FRACTION_PAD_EM
506 } else {
507 FRACTION_PAD_EM * 0.65
508 }
509 }
510
511 fn fraction_numerator_gap(self) -> f32 {
512 self.font_constants()
513 .and_then(|constants| {
514 constants
515 .fraction_numerator_gap(self.size, matches!(self.display, MathDisplay::Block))
516 })
517 .unwrap_or_else(|| self.fraction_gap_fallback())
518 }
519
520 fn fraction_denominator_gap(self) -> f32 {
521 self.font_constants()
522 .and_then(|constants| {
523 constants
524 .fraction_denominator_gap(self.size, matches!(self.display, MathDisplay::Block))
525 })
526 .unwrap_or_else(|| self.fraction_gap_fallback())
527 }
528
529 fn fraction_gap_fallback(self) -> f32 {
530 self.size
531 * if matches!(self.display, MathDisplay::Block) {
532 FRACTION_GAP_EM
533 } else {
534 FRACTION_GAP_EM * 0.55
535 }
536 }
537
538 fn fraction_numerator_shift(self) -> f32 {
539 self.font_constants()
540 .and_then(|constants| {
541 constants
542 .fraction_numerator_shift(self.size, matches!(self.display, MathDisplay::Block))
543 })
544 .unwrap_or(self.size * 0.55)
545 }
546
547 fn fraction_denominator_shift(self) -> f32 {
548 self.font_constants()
549 .and_then(|constants| {
550 constants.fraction_denominator_shift(
551 self.size,
552 matches!(self.display, MathDisplay::Block),
553 )
554 })
555 .unwrap_or(self.size * 0.55)
556 }
557
558 fn math_axis_shift(self) -> f32 {
559 self.font_constants()
560 .and_then(|constants| constants.axis_height(self.size))
561 .or_else(|| {
562 matches!(self.display, MathDisplay::Block)
563 .then(|| self.operator_axis_shift())
564 .flatten()
565 })
566 .unwrap_or(self.size * 0.28)
567 }
568
569 fn operator_axis_shift(self) -> Option<f32> {
570 let layout = math_glyph_layout("+", self.size, FontWeight::Regular);
571 let baseline = layout.lines.first()?.baseline;
572 Some((baseline - layout.line_height * 0.5).max(self.size * 0.2))
573 }
574
575 fn sqrt_gap(self) -> f32 {
576 self.font_constants()
577 .and_then(|constants| {
578 constants
579 .radical_vertical_gap(self.size, matches!(self.display, MathDisplay::Block))
580 })
581 .unwrap_or(self.size * SQRT_GAP_EM)
582 }
583
584 fn radical_width(self) -> f32 {
585 self.size * 0.72
586 }
587
588 fn radical_left_flair_y(self) -> f32 {
589 -self.size * 0.03
590 }
591
592 fn radical_hook_x(self) -> f32 {
593 self.size * 0.12
594 }
595
596 fn radical_hook_y(self) -> f32 {
597 -self.size * 0.1
598 }
599
600 fn radical_tick_x(self) -> f32 {
601 self.size * 0.24
602 }
603
604 fn radical_tick_y(self, inner_descent: f32) -> f32 {
605 (inner_descent * 0.75).max(self.size * 0.13)
606 }
607
608 fn radical_variant_for_height(self, target_height: f32) -> Option<OpenTypeDelimiterVariant> {
609 self.stretchy_variant_for_height(RADICAL_GLYPH, target_height)
610 }
611
612 fn large_operator_variant_for_height(
613 self,
614 operator: &str,
615 target_height: f32,
616 ) -> Option<OpenTypeDelimiterVariant> {
617 let operator = single_char(operator)?;
618 is_large_operator_symbol(operator)
619 .then(|| self.stretchy_variant_for_height(operator, target_height))?
620 }
621
622 fn root_offset_x(self, index_width: f32) -> f32 {
623 self.font_constants()
624 .map(|constants| {
625 let before = constants
626 .radical_kern_before_degree(self.size)
627 .unwrap_or(0.0);
628 let after = constants
629 .radical_kern_after_degree(self.size)
630 .unwrap_or(0.0);
631 (before + index_width + after).max(index_width * 0.35)
632 })
633 .unwrap_or(index_width * 0.55)
634 }
635
636 fn root_index_shift(self, root_ascent: f32, index_descent: f32) -> f32 {
637 self.font_constants()
638 .and_then(|constants| constants.radical_degree_bottom_raise_fraction())
639 .map(|raise| -root_ascent * raise - index_descent)
640 .unwrap_or(-root_ascent * 0.52)
641 }
642
643 fn script_gap(self) -> f32 {
644 self.font_constants()
645 .and_then(|constants| constants.space_after_script(self.size))
646 .unwrap_or(self.size * 0.06)
647 }
648
649 fn superscript_shift(self, base_ascent: f32, sup_descent: f32) -> f32 {
650 let min_shift = self
651 .font_constants()
652 .and_then(|constants| constants.superscript_shift_up(self.size))
653 .unwrap_or(0.0);
654 let bottom_min = self
655 .font_constants()
656 .and_then(|constants| constants.superscript_bottom_min(self.size))
657 .unwrap_or(self.size * 0.18);
658 -(base_ascent * 0.58)
659 .max(min_shift)
660 .max(sup_descent + bottom_min)
661 }
662
663 fn subscript_shift(self, base_descent: f32, sub_ascent: f32) -> f32 {
664 let min_shift = self
665 .font_constants()
666 .and_then(|constants| constants.subscript_shift_down(self.size))
667 .unwrap_or(self.size * 0.28);
668 (base_descent + sub_ascent * 0.72).max(min_shift)
669 }
670
671 fn sub_superscript_gap(self) -> f32 {
672 self.font_constants()
673 .and_then(|constants| constants.sub_superscript_gap_min(self.size))
674 .unwrap_or(self.size * 0.08)
675 }
676
677 fn under_over_gap(self) -> f32 {
678 self.size * 0.12
679 }
680
681 fn upper_limit_gap(self) -> f32 {
682 self.font_constants()
683 .and_then(|constants| constants.upper_limit_gap_min(self.size))
684 .unwrap_or_else(|| self.under_over_gap())
685 }
686
687 fn upper_limit_baseline_rise(self) -> f32 {
688 self.font_constants()
689 .and_then(|constants| constants.upper_limit_baseline_rise_min(self.size))
690 .unwrap_or(self.size * 0.35)
691 }
692
693 fn lower_limit_gap(self) -> f32 {
694 self.font_constants()
695 .and_then(|constants| constants.lower_limit_gap_min(self.size))
696 .unwrap_or_else(|| self.under_over_gap())
697 }
698
699 fn lower_limit_baseline_drop(self) -> f32 {
700 self.font_constants()
701 .and_then(|constants| constants.lower_limit_baseline_drop_min(self.size))
702 .unwrap_or(self.size * 0.35)
703 }
704
705 fn accent_gap(self) -> f32 {
706 self.size * 0.06
707 }
708
709 fn table_col_gap(self, gap_em: Option<f32>) -> f32 {
710 self.size * gap_em.unwrap_or(TABLE_COL_GAP_EM)
711 }
712
713 fn table_row_gap(self, gap_em: Option<f32>) -> f32 {
714 self.size * gap_em.unwrap_or(TABLE_ROW_GAP_EM)
715 }
716
717 fn delimiter_gap(self) -> f32 {
718 self.size * 0.08
719 }
720
721 fn delimiter_overshoot(self) -> f32 {
722 (self.size * 0.08).max(self.rule_thickness()).max(
723 self.font_constants()
724 .and_then(|constants| constants.min_connector_overlap(self.size))
725 .unwrap_or(0.0),
726 )
727 }
728
729 fn delimited_sub_formula_min_height(self) -> f32 {
730 self.font_constants()
731 .and_then(|constants| constants.delimited_sub_formula_min_height(self.size))
732 .unwrap_or(self.size * 1.5)
733 }
734
735 fn should_stretch_delimiter(self, body: &MathLayout) -> bool {
736 body.height() + self.delimiter_overshoot() * 2.0 >= self.delimited_sub_formula_min_height()
737 }
738
739 fn delimiter_variant_for_height(
740 self,
741 delimiter: char,
742 target_height: f32,
743 ) -> Option<OpenTypeDelimiterVariant> {
744 self.stretchy_variant_for_height(delimiter, target_height)
745 }
746
747 fn stretchy_variant_for_height(
748 self,
749 glyph: char,
750 target_height: f32,
751 ) -> Option<OpenTypeDelimiterVariant> {
752 self.font_constants().and_then(|constants| {
753 constants.stretchy_variant_for_height(glyph, target_height, self.size)
754 })
755 }
756
757 fn delimiter_assembly_parts(
758 self,
759 delimiter: char,
760 ) -> Option<Vec<OpenTypeDelimiterAssemblyPart>> {
761 self.font_constants()
762 .and_then(|constants| constants.delimiter_assembly_parts(delimiter))
763 }
764
765 fn delimiter_width(self) -> f32 {
766 self.size * 0.42
767 }
768}
769
770#[derive(Clone, Debug)]
771struct OpenTypeMathConstants {
772 units_per_em: f32,
773 script_percent_scale_down: i16,
774 axis_height: i16,
775 subscript_shift_down: i16,
776 superscript_shift_up: i16,
777 superscript_bottom_min: i16,
778 sub_superscript_gap_min: i16,
779 space_after_script: i16,
780 upper_limit_gap_min: i16,
781 upper_limit_baseline_rise_min: i16,
782 lower_limit_gap_min: i16,
783 lower_limit_baseline_drop_min: i16,
784 fraction_numerator_shift_up: i16,
785 fraction_numerator_display_style_shift_up: i16,
786 fraction_denominator_shift_down: i16,
787 fraction_denominator_display_style_shift_down: i16,
788 fraction_rule_thickness: i16,
789 fraction_numerator_gap_min: i16,
790 fraction_num_display_style_gap_min: i16,
791 fraction_denominator_gap_min: i16,
792 fraction_denom_display_style_gap_min: i16,
793 radical_rule_thickness: i16,
794 radical_vertical_gap: i16,
795 radical_display_style_vertical_gap: i16,
796 radical_kern_before_degree: i16,
797 radical_kern_after_degree: i16,
798 radical_degree_bottom_raise_percent: i16,
799 delimited_sub_formula_min_height: u16,
800 min_connector_overlap: u16,
801 #[cfg_attr(not(test), allow(dead_code))]
802 delimiter_variants: Vec<OpenTypeDelimiterVariants>,
803}
804
805#[cfg_attr(not(test), allow(dead_code))]
806#[derive(Clone, Debug)]
807struct OpenTypeDelimiterVariants {
808 delimiter: char,
809 variants: Vec<OpenTypeDelimiterVariant>,
810 assembly_parts: Vec<OpenTypeDelimiterAssemblyPart>,
811}
812
813#[cfg_attr(not(test), allow(dead_code))]
814#[derive(Clone, Copy, Debug)]
815struct OpenTypeDelimiterVariant {
816 glyph_id: u16,
817 advance: u16,
818 horizontal_advance: u16,
819 bbox: Option<OpenTypeGlyphBBox>,
820}
821
822#[cfg_attr(not(test), allow(dead_code))]
823#[derive(Clone, Copy, Debug)]
824struct OpenTypeDelimiterAssemblyPart {
825 glyph_id: u16,
826 start_connector_length: u16,
827 end_connector_length: u16,
828 full_advance: u16,
829 horizontal_advance: u16,
830 bbox: Option<OpenTypeGlyphBBox>,
831 extender: bool,
832}
833
834#[derive(Clone, Copy, Debug)]
835struct OpenTypeGlyphBBox {
836 x_min: i16,
837 y_min: i16,
838 x_max: i16,
839 y_max: i16,
840}
841
842impl OpenTypeDelimiterVariants {
843 fn max_advance(&self) -> u16 {
844 self.variants
845 .iter()
846 .map(|variant| variant.advance)
847 .chain(self.assembly_parts.iter().map(|part| part.full_advance))
848 .max()
849 .unwrap_or(0)
850 }
851}
852
853impl OpenTypeMathConstants {
854 fn font_units(&self, value: i16, size: f32) -> Option<f32> {
855 (value > 0 && self.units_per_em > 0.0).then(|| value as f32 / self.units_per_em * size)
856 }
857
858 fn signed_font_units(&self, value: i16, size: f32) -> Option<f32> {
859 (value != 0 && self.units_per_em > 0.0).then(|| value as f32 / self.units_per_em * size)
860 }
861
862 fn script_scale(&self, size: f32) -> Option<f32> {
863 (self.script_percent_scale_down > 0)
864 .then(|| size * self.script_percent_scale_down as f32 / 100.0)
865 }
866
867 fn fraction_rule_thickness(&self, size: f32) -> Option<f32> {
868 self.font_units(self.fraction_rule_thickness, size)
869 }
870
871 fn axis_height(&self, size: f32) -> Option<f32> {
872 self.font_units(self.axis_height, size)
873 }
874
875 fn subscript_shift_down(&self, size: f32) -> Option<f32> {
876 self.font_units(self.subscript_shift_down, size)
877 }
878
879 fn superscript_shift_up(&self, size: f32) -> Option<f32> {
880 self.font_units(self.superscript_shift_up, size)
881 }
882
883 fn superscript_bottom_min(&self, size: f32) -> Option<f32> {
884 self.font_units(self.superscript_bottom_min, size)
885 }
886
887 fn sub_superscript_gap_min(&self, size: f32) -> Option<f32> {
888 self.font_units(self.sub_superscript_gap_min, size)
889 }
890
891 fn space_after_script(&self, size: f32) -> Option<f32> {
892 self.font_units(self.space_after_script, size)
893 }
894
895 fn upper_limit_gap_min(&self, size: f32) -> Option<f32> {
896 self.font_units(self.upper_limit_gap_min, size)
897 }
898
899 fn upper_limit_baseline_rise_min(&self, size: f32) -> Option<f32> {
900 self.font_units(self.upper_limit_baseline_rise_min, size)
901 }
902
903 fn lower_limit_gap_min(&self, size: f32) -> Option<f32> {
904 self.font_units(self.lower_limit_gap_min, size)
905 }
906
907 fn lower_limit_baseline_drop_min(&self, size: f32) -> Option<f32> {
908 self.font_units(self.lower_limit_baseline_drop_min, size)
909 }
910
911 fn fraction_numerator_shift(&self, size: f32, display: bool) -> Option<f32> {
912 let value = if display {
913 self.fraction_numerator_display_style_shift_up
914 } else {
915 self.fraction_numerator_shift_up
916 };
917 self.font_units(value, size)
918 }
919
920 fn fraction_denominator_shift(&self, size: f32, display: bool) -> Option<f32> {
921 let value = if display {
922 self.fraction_denominator_display_style_shift_down
923 } else {
924 self.fraction_denominator_shift_down
925 };
926 self.font_units(value, size)
927 }
928
929 fn fraction_numerator_gap(&self, size: f32, display: bool) -> Option<f32> {
930 let value = if display {
931 self.fraction_num_display_style_gap_min
932 } else {
933 self.fraction_numerator_gap_min
934 };
935 self.font_units(value, size)
936 }
937
938 fn fraction_denominator_gap(&self, size: f32, display: bool) -> Option<f32> {
939 let value = if display {
940 self.fraction_denom_display_style_gap_min
941 } else {
942 self.fraction_denominator_gap_min
943 };
944 self.font_units(value, size)
945 }
946
947 fn radical_rule_thickness(&self, size: f32) -> Option<f32> {
948 self.font_units(self.radical_rule_thickness, size)
949 }
950
951 fn radical_vertical_gap(&self, size: f32, display: bool) -> Option<f32> {
952 let value = if display {
953 self.radical_display_style_vertical_gap
954 } else {
955 self.radical_vertical_gap
956 };
957 self.font_units(value, size)
958 }
959
960 fn radical_kern_before_degree(&self, size: f32) -> Option<f32> {
961 self.signed_font_units(self.radical_kern_before_degree, size)
962 }
963
964 fn radical_kern_after_degree(&self, size: f32) -> Option<f32> {
965 self.signed_font_units(self.radical_kern_after_degree, size)
966 }
967
968 fn radical_degree_bottom_raise_fraction(&self) -> Option<f32> {
969 (self.radical_degree_bottom_raise_percent > 0)
970 .then(|| self.radical_degree_bottom_raise_percent as f32 / 100.0)
971 }
972
973 #[cfg_attr(not(test), allow(dead_code))]
974 fn delimiter_variant_count(&self, delimiter: char) -> usize {
975 self.delimiter_variants
976 .iter()
977 .find(|variants| variants.delimiter == delimiter)
978 .map(|variants| variants.variants.len())
979 .unwrap_or(0)
980 }
981
982 #[cfg_attr(not(test), allow(dead_code))]
983 fn delimiter_assembly_part_count(&self, delimiter: char) -> usize {
984 self.delimiter_variants
985 .iter()
986 .find(|variants| variants.delimiter == delimiter)
987 .map(|variants| variants.assembly_parts.len())
988 .unwrap_or(0)
989 }
990
991 #[cfg_attr(not(test), allow(dead_code))]
992 fn delimiter_max_advance(&self, delimiter: char, size: f32) -> Option<f32> {
993 let advance = self
994 .delimiter_variants
995 .iter()
996 .find(|variants| variants.delimiter == delimiter)?
997 .max_advance();
998 (advance > 0 && self.units_per_em > 0.0).then(|| advance as f32 / self.units_per_em * size)
999 }
1000
1001 #[cfg_attr(not(test), allow(dead_code))]
1002 fn delimiter_extender_part_count(&self, delimiter: char) -> usize {
1003 self.delimiter_variants
1004 .iter()
1005 .find(|variants| variants.delimiter == delimiter)
1006 .map(|variants| {
1007 variants
1008 .assembly_parts
1009 .iter()
1010 .filter(|part| part.extender)
1011 .count()
1012 })
1013 .unwrap_or(0)
1014 }
1015
1016 fn stretchy_variant_for_height(
1017 &self,
1018 glyph: char,
1019 target_height: f32,
1020 size: f32,
1021 ) -> Option<OpenTypeDelimiterVariant> {
1022 let variants = self
1023 .delimiter_variants
1024 .iter()
1025 .find(|variants| variants.delimiter == glyph)?;
1026 variants.variants.iter().copied().find(|variant| {
1027 self.units_per_em > 0.0
1028 && variant.advance as f32 / self.units_per_em * size >= target_height
1029 })
1030 }
1031
1032 fn delimiter_assembly_parts(
1033 &self,
1034 delimiter: char,
1035 ) -> Option<Vec<OpenTypeDelimiterAssemblyPart>> {
1036 let variants = self
1037 .delimiter_variants
1038 .iter()
1039 .find(|variants| variants.delimiter == delimiter)?;
1040 (!variants.assembly_parts.is_empty()).then(|| variants.assembly_parts.clone())
1041 }
1042
1043 #[cfg_attr(not(test), allow(dead_code))]
1044 fn delimiter_first_variant_glyph_id(&self, delimiter: char) -> Option<u16> {
1045 self.delimiter_variants
1046 .iter()
1047 .find(|variants| variants.delimiter == delimiter)?
1048 .variants
1049 .first()
1050 .map(|variant| variant.glyph_id)
1051 }
1052
1053 #[cfg_attr(not(test), allow(dead_code))]
1054 fn delimiter_has_assembly_connectors(&self, delimiter: char) -> bool {
1055 self.delimiter_variants
1056 .iter()
1057 .find(|variants| variants.delimiter == delimiter)
1058 .is_some_and(|variants| {
1059 variants.assembly_parts.iter().any(|part| {
1060 part.glyph_id > 0
1061 && (part.start_connector_length > 0 || part.end_connector_length > 0)
1062 })
1063 })
1064 }
1065
1066 fn min_connector_overlap(&self, size: f32) -> Option<f32> {
1067 (self.min_connector_overlap > 0 && self.units_per_em > 0.0)
1068 .then(|| self.min_connector_overlap as f32 / self.units_per_em * size)
1069 }
1070
1071 fn delimited_sub_formula_min_height(&self, size: f32) -> Option<f32> {
1072 (self.delimited_sub_formula_min_height > 0 && self.units_per_em > 0.0)
1073 .then(|| self.delimited_sub_formula_min_height as f32 / self.units_per_em * size)
1074 }
1075}
1076
1077fn open_type_math_constants() -> Option<OpenTypeMathConstants> {
1078 #[cfg(feature = "symbols")]
1079 {
1080 static CONSTANTS: std::sync::OnceLock<Option<OpenTypeMathConstants>> =
1081 std::sync::OnceLock::new();
1082 CONSTANTS
1083 .get_or_init(|| parse_open_type_math_constants(damascene_fonts::NOTO_SANS_MATH_REGULAR))
1084 .clone()
1085 }
1086 #[cfg(not(feature = "symbols"))]
1087 {
1088 None
1089 }
1090}
1091
1092#[cfg(feature = "symbols")]
1093fn parse_open_type_math_constants(font: &[u8]) -> Option<OpenTypeMathConstants> {
1094 let face = ttf_parser::Face::parse(font, 0).ok()?;
1095 let math = face.tables().math?;
1096 let constants = math.constants?;
1097 Some(OpenTypeMathConstants {
1098 units_per_em: face.units_per_em() as f32,
1099 script_percent_scale_down: constants.script_percent_scale_down(),
1100 axis_height: constants.axis_height().value,
1101 subscript_shift_down: constants.subscript_shift_down().value,
1102 superscript_shift_up: constants.superscript_shift_up().value,
1103 superscript_bottom_min: constants.superscript_bottom_min().value,
1104 sub_superscript_gap_min: constants.sub_superscript_gap_min().value,
1105 space_after_script: constants.space_after_script().value,
1106 upper_limit_gap_min: constants.upper_limit_gap_min().value,
1107 upper_limit_baseline_rise_min: constants.upper_limit_baseline_rise_min().value,
1108 lower_limit_gap_min: constants.lower_limit_gap_min().value,
1109 lower_limit_baseline_drop_min: constants.lower_limit_baseline_drop_min().value,
1110 fraction_numerator_shift_up: constants.fraction_numerator_shift_up().value,
1111 fraction_numerator_display_style_shift_up: constants
1112 .fraction_numerator_display_style_shift_up()
1113 .value,
1114 fraction_denominator_shift_down: constants.fraction_denominator_shift_down().value,
1115 fraction_denominator_display_style_shift_down: constants
1116 .fraction_denominator_display_style_shift_down()
1117 .value,
1118 fraction_rule_thickness: constants.fraction_rule_thickness().value,
1119 fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
1120 fraction_num_display_style_gap_min: constants.fraction_num_display_style_gap_min().value,
1121 fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
1122 fraction_denom_display_style_gap_min: constants
1123 .fraction_denom_display_style_gap_min()
1124 .value,
1125 radical_rule_thickness: constants.radical_rule_thickness().value,
1126 radical_vertical_gap: constants.radical_vertical_gap().value,
1127 radical_display_style_vertical_gap: constants.radical_display_style_vertical_gap().value,
1128 radical_kern_before_degree: constants.radical_kern_before_degree().value,
1129 radical_kern_after_degree: constants.radical_kern_after_degree().value,
1130 radical_degree_bottom_raise_percent: constants.radical_degree_bottom_raise_percent(),
1131 delimited_sub_formula_min_height: constants.delimited_sub_formula_min_height(),
1132 min_connector_overlap: math
1133 .variants
1134 .map(|variants| variants.min_connector_overlap)
1135 .unwrap_or(0),
1136 delimiter_variants: parse_open_type_delimiter_variants(&face, math.variants),
1137 })
1138}
1139
1140#[cfg(feature = "symbols")]
1141fn parse_open_type_delimiter_variants(
1142 face: &ttf_parser::Face<'_>,
1143 variants: Option<ttf_parser::math::Variants<'_>>,
1144) -> Vec<OpenTypeDelimiterVariants> {
1145 let Some(variants) = variants else {
1146 return Vec::new();
1147 };
1148 STRETCHY_VARIANT_CHARS
1149 .into_iter()
1150 .filter_map(|delimiter| {
1151 let glyph = face.glyph_index(delimiter)?;
1152 let construction = variants.vertical_constructions.get(glyph)?;
1153 let glyph_variants = construction
1154 .variants
1155 .into_iter()
1156 .map(|variant| OpenTypeDelimiterVariant {
1157 glyph_id: variant.variant_glyph.0,
1158 advance: variant.advance_measurement,
1159 horizontal_advance: face.glyph_hor_advance(variant.variant_glyph).unwrap_or(0),
1160 bbox: face.glyph_bounding_box(variant.variant_glyph).map(|bbox| {
1161 OpenTypeGlyphBBox {
1162 x_min: bbox.x_min,
1163 y_min: bbox.y_min,
1164 x_max: bbox.x_max,
1165 y_max: bbox.y_max,
1166 }
1167 }),
1168 })
1169 .collect();
1170 let assembly_parts = construction
1171 .assembly
1172 .map(|assembly| {
1173 assembly
1174 .parts
1175 .into_iter()
1176 .map(|part| OpenTypeDelimiterAssemblyPart {
1177 glyph_id: part.glyph_id.0,
1178 start_connector_length: part.start_connector_length,
1179 end_connector_length: part.end_connector_length,
1180 full_advance: part.full_advance,
1181 horizontal_advance: face.glyph_hor_advance(part.glyph_id).unwrap_or(0),
1182 bbox: face.glyph_bounding_box(part.glyph_id).map(|bbox| {
1183 OpenTypeGlyphBBox {
1184 x_min: bbox.x_min,
1185 y_min: bbox.y_min,
1186 x_max: bbox.x_max,
1187 y_max: bbox.y_max,
1188 }
1189 }),
1190 extender: part.part_flags.extender(),
1191 })
1192 .collect()
1193 })
1194 .unwrap_or_default();
1195 Some(OpenTypeDelimiterVariants {
1196 delimiter,
1197 variants: glyph_variants,
1198 assembly_parts,
1199 })
1200 })
1201 .collect()
1202}
1203
1204pub fn layout_math(expr: &MathExpr, size: f32, display: MathDisplay) -> MathLayout {
1211 layout_expr(expr, LayoutCtx { size, display })
1212}
1213
1214fn layout_expr(expr: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1215 let metrics = ctx.metrics();
1216 match expr {
1217 MathExpr::Source { body, .. } => layout_expr(body, ctx),
1218 MathExpr::Row(children) => layout_row(children, ctx),
1219 MathExpr::Identifier(s) => layout_glyph(s, ctx, FontWeight::Regular, true),
1220 MathExpr::Number(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1221 MathExpr::Operator(s) => layout_operator(s, ctx),
1222 MathExpr::OperatorWithMetadata {
1223 text,
1224 lspace,
1225 rspace,
1226 large_operator,
1227 ..
1228 } => layout_operator_with_spacing(text, *lspace, *rspace, *large_operator, ctx),
1229 MathExpr::Text(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1230 MathExpr::Space(em) => MathLayout {
1231 width: metrics.space_width(*em),
1232 ascent: metrics.default_ascent(),
1233 descent: metrics.default_descent(),
1234 atoms: Vec::new(),
1235 },
1236 MathExpr::Fraction {
1237 numerator,
1238 denominator,
1239 } => layout_fraction(numerator, denominator, ctx),
1240 MathExpr::Sqrt(child) => layout_sqrt(child, ctx),
1241 MathExpr::Root { base, index } => layout_root(base, index, ctx),
1242 MathExpr::Scripts { base, sub, sup } => {
1243 layout_scripts(base, sub.as_deref(), sup.as_deref(), ctx)
1244 }
1245 MathExpr::UnderOver { base, under, over } => {
1246 layout_under_over(base, under.as_deref(), over.as_deref(), ctx)
1247 }
1248 MathExpr::Accent {
1249 base,
1250 accent,
1251 stretch,
1252 } => layout_accent(base, accent, *stretch, ctx),
1253 MathExpr::Fenced { open, close, body } => layout_fenced(open, close, body, ctx),
1254 MathExpr::Table {
1255 rows,
1256 column_alignments,
1257 column_gap,
1258 row_gap,
1259 } => layout_table(rows, column_alignments, *column_gap, *row_gap, ctx),
1260 MathExpr::Error(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1261 }
1262}
1263
1264fn layout_row(children: &[MathExpr], ctx: LayoutCtx) -> MathLayout {
1265 let mut width = 0.0;
1266 let metrics = ctx.metrics();
1267 let mut ascent: f32 = metrics.default_ascent();
1268 let mut descent: f32 = metrics.default_descent();
1269 let mut atoms = Vec::new();
1270 for child in children {
1271 let child_layout = layout_expr(child, ctx);
1272 translate_atoms(&mut atoms, child_layout.atoms, width, 0.0);
1273 width += child_layout.width;
1274 ascent = ascent.max(child_layout.ascent);
1275 descent = descent.max(child_layout.descent);
1276 }
1277 MathLayout {
1278 width,
1279 ascent,
1280 descent,
1281 atoms,
1282 }
1283}
1284
1285fn layout_glyph(s: &str, ctx: LayoutCtx, weight: FontWeight, italic: bool) -> MathLayout {
1286 if s.is_empty() {
1287 return MathLayout {
1288 width: 0.0,
1289 ascent: 0.0,
1290 descent: 0.0,
1291 atoms: Vec::new(),
1292 };
1293 }
1294 let measured = text_metrics::measure_text(s, ctx.size, weight, false, TextWrap::NoWrap, None);
1295 MathLayout {
1296 width: measured.width,
1297 ascent: ctx.metrics().glyph_ascent(),
1298 descent: ctx.metrics().glyph_descent(),
1299 atoms: vec![MathAtom::Glyph {
1300 text: s.to_string(),
1301 x: 0.0,
1302 y_baseline: 0.0,
1303 size: ctx.size,
1304 weight,
1305 italic,
1306 }],
1307 }
1308}
1309
1310fn layout_operator(s: &str, ctx: LayoutCtx) -> MathLayout {
1311 layout_operator_with_spacing(s, None, None, None, ctx)
1312}
1313
1314fn layout_operator_with_spacing(
1315 s: &str,
1316 lspace: Option<f32>,
1317 rspace: Option<f32>,
1318 large_operator: Option<bool>,
1319 ctx: LayoutCtx,
1320) -> MathLayout {
1321 let use_large_operator = large_operator.unwrap_or_else(|| is_large_operator_symbol_str(s));
1322 let glyph_ctx = if matches!(ctx.display, MathDisplay::Block) && use_large_operator {
1323 ctx.large_operator()
1324 } else {
1325 ctx
1326 };
1327 if matches!(ctx.display, MathDisplay::Block) && use_large_operator {
1328 let operator = MathExpr::OperatorWithMetadata {
1329 text: s.into(),
1330 lspace,
1331 rspace,
1332 large_operator: Some(true),
1333 movable_limits: None,
1334 };
1335 if let Some(layout) = layout_large_operator_variant(&operator, glyph_ctx) {
1336 return layout;
1337 }
1338 }
1339 layout_operator_glyph_with_spacing(s, lspace, rspace, glyph_ctx)
1340}
1341
1342fn layout_operator_glyph_with_spacing(
1343 s: &str,
1344 lspace: Option<f32>,
1345 rspace: Option<f32>,
1346 ctx: LayoutCtx,
1347) -> MathLayout {
1348 let mut layout = layout_glyph(s, ctx, FontWeight::Regular, false);
1349 let (lspace, rspace) = ctx
1350 .metrics()
1351 .operator_spacing_with_overrides(s, lspace, rspace);
1352 if lspace != 0.0 || rspace != 0.0 {
1356 for atom in &mut layout.atoms {
1357 if let MathAtom::Glyph { x, .. } = atom {
1358 *x += lspace;
1359 }
1360 }
1361 layout.width += lspace + rspace;
1362 }
1363 layout
1364}
1365
1366fn layout_operator_expr_glyph_fallback(expr: &MathExpr, ctx: LayoutCtx) -> Option<MathLayout> {
1367 match expr.without_source() {
1368 MathExpr::Operator(s) => Some(layout_operator_glyph_with_spacing(s, None, None, ctx)),
1369 MathExpr::OperatorWithMetadata {
1370 text,
1371 lspace,
1372 rspace,
1373 ..
1374 } => Some(layout_operator_glyph_with_spacing(
1375 text, *lspace, *rspace, ctx,
1376 )),
1377 _ => None,
1378 }
1379}
1380
1381fn layout_fraction(numerator: &MathExpr, denominator: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1382 let metrics = ctx.metrics();
1383 let child_ctx = if matches!(ctx.display, MathDisplay::Block) {
1384 ctx
1385 } else {
1386 ctx.script()
1387 };
1388 let num = layout_expr(numerator, child_ctx);
1389 let den = layout_expr(denominator, child_ctx);
1390 let pad = metrics.fraction_pad();
1391 let num_gap = metrics.fraction_numerator_gap();
1392 let den_gap = metrics.fraction_denominator_gap();
1393 let rule = metrics.rule_thickness();
1394 let axis_shift = metrics.math_axis_shift();
1398 let rule_center_y = -axis_shift;
1399 let width = num.width.max(den.width) + pad * 2.0;
1400 let num_x = (width - num.width) * 0.5;
1401 let den_x = (width - den.width) * 0.5;
1402 let num_dy = (rule_center_y - num_gap - rule * 0.5 - num.descent)
1403 .min(-metrics.fraction_numerator_shift());
1404 let den_dy = (rule_center_y + den_gap + rule * 0.5 + den.ascent)
1405 .max(metrics.fraction_denominator_shift());
1406 let ascent = -num_dy + num.ascent;
1407 let descent = den_dy + den.descent;
1408 let mut atoms = Vec::new();
1409 translate_atoms(&mut atoms, num.atoms, num_x, num_dy);
1410 atoms.push(MathAtom::Rule {
1411 rect: Rect::new(0.0, rule_center_y - rule * 0.5, width, rule),
1412 });
1413 translate_atoms(&mut atoms, den.atoms, den_x, den_dy);
1414 MathLayout {
1415 width,
1416 ascent,
1417 descent,
1418 atoms,
1419 }
1420}
1421
1422fn layout_sqrt(child: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1423 let metrics = ctx.metrics();
1424 let inner = layout_expr(child, ctx);
1425 let gap = metrics.sqrt_gap();
1426 let rule = metrics.radical_rule_thickness();
1427 if let Some(layout) = layout_open_type_sqrt(inner.clone(), gap, rule, ctx) {
1428 return layout;
1429 }
1430 layout_vector_sqrt(inner, gap, rule, ctx)
1431}
1432
1433fn layout_vector_sqrt(inner: MathLayout, gap: f32, rule: f32, ctx: LayoutCtx) -> MathLayout {
1434 let metrics = ctx.metrics();
1435 let radical_w = metrics.radical_width();
1436 let inner_x = radical_w + gap;
1437 let bar_y = -inner.ascent - gap - rule * 0.5;
1438 let tick_y = metrics.radical_tick_y(inner.descent);
1439 let end_x = inner_x + inner.width;
1440 let mut atoms = Vec::new();
1441 atoms.push(MathAtom::Radical {
1442 points: [
1443 [0.0, metrics.radical_left_flair_y()],
1444 [metrics.radical_hook_x(), metrics.radical_hook_y()],
1445 [metrics.radical_tick_x(), tick_y],
1446 [radical_w, bar_y],
1447 [end_x, bar_y],
1448 ],
1449 thickness: rule,
1450 });
1451 translate_atoms(&mut atoms, inner.atoms, inner_x, 0.0);
1452 MathLayout {
1453 width: end_x,
1454 ascent: -bar_y + rule * 0.5,
1455 descent: tick_y + rule * 0.5,
1456 atoms,
1457 }
1458}
1459
1460fn layout_open_type_sqrt(
1461 inner: MathLayout,
1462 gap: f32,
1463 rule: f32,
1464 ctx: LayoutCtx,
1465) -> Option<MathLayout> {
1466 let metrics = ctx.metrics();
1467 let bar_y = -inner.ascent - gap - rule * 0.5;
1468 let tick_y = metrics.radical_tick_y(inner.descent);
1469 let target_height = tick_y - bar_y + rule;
1470 let variant = metrics.radical_variant_for_height(target_height)?;
1471 let bbox = variant.bbox?;
1472 let constants = metrics.font_constants()?;
1473 let scale = metrics.size / constants.units_per_em;
1474 let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1475 if view_box.w <= 0.0 || view_box.h <= 0.0 {
1476 return None;
1477 }
1478 let radical_w = view_box.w * scale;
1479 let radical_h = view_box.h * scale;
1480 let radical_rect = Rect::new(0.0, bar_y - rule * 0.5, radical_w, radical_h);
1481 let inner_x = radical_w + gap;
1482 let end_x = inner_x + inner.width;
1483 let overbar_x = (radical_w - rule * 0.5).max(0.0);
1484 let mut atoms = Vec::new();
1485 atoms.push(MathAtom::GlyphId {
1486 glyph_id: variant.glyph_id,
1487 rect: radical_rect,
1488 view_box,
1489 });
1490 atoms.push(MathAtom::Rule {
1491 rect: Rect::new(
1492 overbar_x,
1493 bar_y - rule * 0.5,
1494 (end_x - overbar_x).max(rule),
1495 rule,
1496 ),
1497 });
1498 translate_atoms(&mut atoms, inner.atoms, inner_x, 0.0);
1499 Some(MathLayout {
1500 width: end_x,
1501 ascent: (-bar_y + rule * 0.5).max(-radical_rect.y),
1502 descent: (tick_y + rule * 0.5).max(radical_rect.y + radical_rect.h),
1503 atoms,
1504 })
1505}
1506
1507fn layout_root(base: &MathExpr, index: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1508 let metrics = ctx.metrics();
1509 let root = layout_sqrt(base, ctx);
1510 let index = layout_expr(index, ctx.script());
1511 let root_x = metrics.root_offset_x(index.width);
1512 let index_dy = metrics.root_index_shift(root.ascent, index.descent);
1513 let mut atoms = Vec::new();
1514 translate_atoms(&mut atoms, index.atoms, 0.0, index_dy);
1515 translate_atoms(&mut atoms, root.atoms, root_x, 0.0);
1516 MathLayout {
1517 width: root_x + root.width,
1518 ascent: root.ascent.max(-index_dy + index.ascent),
1519 descent: root.descent.max(index_dy + index.descent),
1520 atoms,
1521 }
1522}
1523
1524fn layout_scripts(
1525 base: &MathExpr,
1526 sub: Option<&MathExpr>,
1527 sup: Option<&MathExpr>,
1528 ctx: LayoutCtx,
1529) -> MathLayout {
1530 if matches!(ctx.display, MathDisplay::Block) && is_display_limits_base(base) {
1531 return layout_under_over(base, sub, sup, ctx);
1532 }
1533 let display_large_operator =
1534 matches!(ctx.display, MathDisplay::Block) && is_large_operator_base(base);
1535 let base_ctx = if display_large_operator {
1536 ctx.large_operator()
1537 } else {
1538 ctx
1539 };
1540 let base_layout = if display_large_operator {
1541 layout_large_operator_variant(base, base_ctx)
1542 .or_else(|| layout_operator_expr_glyph_fallback(base, base_ctx))
1543 .unwrap_or_else(|| layout_expr(base, ctx))
1544 } else {
1545 layout_expr(base, base_ctx)
1546 };
1547 let script_ctx = ctx.script();
1548 let sub_layout = sub.map(|expr| layout_expr(expr, script_ctx));
1549 let sup_layout = sup.map(|expr| layout_expr(expr, script_ctx));
1550 let metrics = ctx.metrics();
1551 let script_gap = metrics.script_gap();
1552 let script_x = base_layout.width + script_gap;
1553 let sup_dy = sup_layout
1554 .as_ref()
1555 .map(|sup| metrics.superscript_shift(base_layout.ascent, sup.descent))
1556 .unwrap_or(0.0);
1557 let mut sub_dy = sub_layout
1558 .as_ref()
1559 .map(|sub| metrics.subscript_shift(base_layout.descent, sub.ascent))
1560 .unwrap_or(0.0);
1561 if let (Some(sub), Some(sup)) = (&sub_layout, &sup_layout) {
1562 let sup_bottom = sup_dy + sup.descent;
1563 let sub_top = sub_dy - sub.ascent;
1564 let gap = sub_top - sup_bottom;
1565 let min_gap = metrics.sub_superscript_gap();
1566 if gap < min_gap {
1567 sub_dy += min_gap - gap;
1568 }
1569 }
1570 let mut atoms = Vec::new();
1571 translate_atoms(&mut atoms, base_layout.atoms, 0.0, 0.0);
1572 let mut script_width: f32 = 0.0;
1573 let mut ascent = base_layout.ascent;
1574 let mut descent = base_layout.descent;
1575 if let Some(sup) = sup_layout {
1576 script_width = script_width.max(sup.width);
1577 ascent = ascent.max(-sup_dy + sup.ascent);
1578 translate_atoms(&mut atoms, sup.atoms, script_x, sup_dy);
1579 }
1580 if let Some(sub) = sub_layout {
1581 script_width = script_width.max(sub.width);
1582 descent = descent.max(sub_dy + sub.descent);
1583 translate_atoms(&mut atoms, sub.atoms, script_x, sub_dy);
1584 }
1585 MathLayout {
1586 width: base_layout.width + script_gap + script_width,
1587 ascent,
1588 descent,
1589 atoms,
1590 }
1591}
1592
1593fn layout_under_over(
1594 base: &MathExpr,
1595 under: Option<&MathExpr>,
1596 over: Option<&MathExpr>,
1597 ctx: LayoutCtx,
1598) -> MathLayout {
1599 let center_large_operator =
1600 matches!(ctx.display, MathDisplay::Block) && is_large_operator_base(base);
1601 let base_ctx = if center_large_operator {
1602 ctx.large_operator()
1603 } else {
1604 ctx
1605 };
1606 let base_layout = if center_large_operator {
1607 layout_large_operator_variant(base, base_ctx)
1608 .or_else(|| layout_operator_expr_glyph_fallback(base, base_ctx))
1609 .unwrap_or_else(|| layout_expr(base, ctx))
1610 } else {
1611 layout_expr(base, base_ctx)
1612 };
1613 let script_ctx = ctx.script();
1614 let under_layout = under.map(|expr| layout_expr(expr, script_ctx));
1615 let over_layout = over.map(|expr| layout_expr(expr, script_ctx));
1616 let metrics = ctx.metrics();
1617 let width = base_layout
1618 .width
1619 .max(under_layout.as_ref().map(|l| l.width).unwrap_or(0.0))
1620 .max(over_layout.as_ref().map(|l| l.width).unwrap_or(0.0));
1621 let base_x = (width - base_layout.width) * 0.5;
1622 let base_dy = if center_large_operator {
1623 base_ctx.metrics().math_axis_shift() - ctx.metrics().math_axis_shift()
1624 } else {
1625 0.0
1626 };
1627 let base_top = -base_layout.ascent + base_dy;
1628 let base_bottom = base_layout.descent + base_dy;
1629 let mut atoms = Vec::new();
1630 let mut ascent = -base_top;
1631 let mut descent = base_bottom;
1632 translate_atoms(&mut atoms, base_layout.atoms, base_x, base_dy);
1633 if let Some(over) = over_layout {
1634 let over_x = (width - over.width) * 0.5;
1635 let over_dy = (base_top - metrics.upper_limit_gap() - over.descent)
1636 .min(base_dy - metrics.upper_limit_baseline_rise());
1637 ascent = ascent.max(-over_dy + over.ascent);
1638 translate_atoms(&mut atoms, over.atoms, over_x, over_dy);
1639 }
1640 if let Some(under) = under_layout {
1641 let under_x = (width - under.width) * 0.5;
1642 let under_dy = (base_bottom + metrics.lower_limit_gap() + under.ascent)
1643 .max(base_dy + metrics.lower_limit_baseline_drop());
1644 descent = descent.max(under_dy + under.descent);
1645 translate_atoms(&mut atoms, under.atoms, under_x, under_dy);
1646 }
1647 MathLayout {
1648 width,
1649 ascent,
1650 descent,
1651 atoms,
1652 }
1653}
1654
1655fn layout_accent(base: &MathExpr, accent: &MathExpr, stretch: bool, ctx: LayoutCtx) -> MathLayout {
1656 let base_layout = layout_expr(base, ctx);
1657 if stretch && is_overline_accent(accent) {
1658 return layout_overline(base_layout, ctx);
1659 }
1660
1661 let accent_layout = layout_accent_mark(accent, ctx.script());
1662 let metrics = ctx.metrics();
1663 let gap = metrics.accent_gap();
1664 let width = base_layout.width.max(accent_layout.width);
1665 let base_x = (width - base_layout.width) * 0.5;
1666 let accent_x = (width - accent_layout.width) * 0.5;
1667 let accent_dy = -base_layout.ascent - gap - accent_layout.descent;
1668 let mut atoms = Vec::new();
1669 translate_atoms(&mut atoms, base_layout.atoms, base_x, 0.0);
1670 translate_atoms(&mut atoms, accent_layout.atoms, accent_x, accent_dy);
1671 MathLayout {
1672 width,
1673 ascent: base_layout.ascent.max(-accent_dy + accent_layout.ascent),
1674 descent: base_layout.descent,
1675 atoms,
1676 }
1677}
1678
1679fn layout_overline(base_layout: MathLayout, ctx: LayoutCtx) -> MathLayout {
1680 let metrics = ctx.metrics();
1681 let rule = metrics.rule_thickness();
1682 let gap = metrics.accent_gap();
1683 let rule_y = -base_layout.ascent - gap - rule;
1684 let mut atoms = Vec::new();
1685 translate_atoms(&mut atoms, base_layout.atoms, 0.0, 0.0);
1686 atoms.push(MathAtom::Rule {
1687 rect: Rect::new(0.0, rule_y, base_layout.width.max(rule), rule),
1688 });
1689 MathLayout {
1690 width: base_layout.width,
1691 ascent: (-rule_y).max(base_layout.ascent),
1692 descent: base_layout.descent,
1693 atoms,
1694 }
1695}
1696
1697fn is_overline_accent(expr: &MathExpr) -> bool {
1698 matches!(
1699 expr.without_source(),
1700 MathExpr::Operator(s) | MathExpr::Text(s) | MathExpr::Identifier(s)
1701 if matches!(s.as_str(), "¯" | "‾")
1702 )
1703}
1704
1705fn layout_accent_mark(accent: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1706 match accent.without_source() {
1707 MathExpr::Operator(s) if s == "^" => layout_operator("ˆ", ctx),
1708 MathExpr::Operator(s) if s == "~" => layout_operator("˜", ctx),
1709 _ => layout_expr(accent, ctx),
1710 }
1711}
1712
1713fn is_display_limits_base(expr: &MathExpr) -> bool {
1714 match expr.without_source() {
1715 MathExpr::Operator(_) | MathExpr::OperatorWithMetadata { .. } => has_movable_limits(expr),
1716 MathExpr::Text(s) => matches!(
1717 s.as_str(),
1718 "lim" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "Pr" | "lim inf" | "lim sup"
1719 ),
1720 _ => false,
1721 }
1722}
1723
1724fn has_movable_limits(expr: &MathExpr) -> bool {
1725 match expr.without_source() {
1726 MathExpr::Operator(s) => operator_info(s).movable_limits,
1727 MathExpr::OperatorWithMetadata {
1728 text,
1729 movable_limits,
1730 ..
1731 } => movable_limits.unwrap_or_else(|| operator_info(text).movable_limits),
1732 _ => false,
1733 }
1734}
1735
1736fn is_large_operator_base(expr: &MathExpr) -> bool {
1737 match expr.without_source() {
1738 MathExpr::Operator(s) => is_large_operator_symbol_str(s),
1739 MathExpr::OperatorWithMetadata {
1740 text,
1741 large_operator,
1742 ..
1743 } => large_operator.unwrap_or_else(|| operator_info(text).large_operator),
1744 _ => false,
1745 }
1746}
1747
1748fn is_large_operator_symbol_str(s: &str) -> bool {
1749 operator_info(s).large_operator
1750}
1751
1752fn is_large_operator_symbol(ch: char) -> bool {
1753 operator_info(&ch.to_string()).large_operator
1754}
1755
1756fn layout_large_operator_variant(expr: &MathExpr, ctx: LayoutCtx) -> Option<MathLayout> {
1757 let (operator, lspace_override, rspace_override) = match expr.without_source() {
1758 MathExpr::Operator(operator) => (operator.as_str(), None, None),
1759 MathExpr::OperatorWithMetadata {
1760 text,
1761 lspace,
1762 rspace,
1763 ..
1764 } => (text.as_str(), *lspace, *rspace),
1765 _ => return None,
1766 };
1767 let metrics = ctx.metrics();
1768 let variant = metrics.large_operator_variant_for_height(operator, ctx.size)?;
1769 let bbox = variant.bbox?;
1770 let constants = metrics.font_constants()?;
1771 let scale = metrics.size / constants.units_per_em;
1772 let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1773 let glyph_width = view_box.w * scale;
1774 let glyph_height = view_box.h * scale;
1775 if glyph_width <= 0.0 || glyph_height <= 0.0 {
1776 return None;
1777 }
1778 let width = (variant.horizontal_advance as f32 * scale).max(glyph_width);
1779 let target_center_y = -metrics.math_axis_shift();
1780 let glyph_center_y = view_box.y * scale + glyph_height * 0.5;
1781 let glyph_y = target_center_y - glyph_center_y;
1782 let (lspace, rspace) =
1783 metrics.operator_spacing_with_overrides(operator, lspace_override, rspace_override);
1784 let rect = Rect::new(
1785 lspace + (width - glyph_width) * 0.5,
1786 glyph_y + view_box.y * scale,
1787 glyph_width,
1788 glyph_height,
1789 );
1790 Some(MathLayout {
1791 width: width + lspace + rspace,
1792 ascent: -rect.y,
1793 descent: rect.y + rect.h,
1794 atoms: vec![MathAtom::GlyphId {
1795 glyph_id: variant.glyph_id,
1796 rect,
1797 view_box,
1798 }],
1799 })
1800}
1801
1802fn single_char(s: &str) -> Option<char> {
1803 let mut chars = s.chars();
1804 let ch = chars.next()?;
1805 chars.next().is_none().then_some(ch)
1806}
1807
1808fn layout_fenced(
1809 open: &Option<String>,
1810 close: &Option<String>,
1811 body: &MathExpr,
1812 ctx: LayoutCtx,
1813) -> MathLayout {
1814 let body_layout = layout_expr(body, ctx);
1815 let delimiter_rect = delimiter_rect(&body_layout, ctx);
1816 let metrics = ctx.metrics();
1817 let gap = metrics.delimiter_gap();
1818 let stretch_delimiters = metrics.should_stretch_delimiter(&body_layout);
1819 let open_layout = open
1820 .as_deref()
1821 .map(|delimiter| layout_delimiter(delimiter, delimiter_rect, stretch_delimiters, ctx));
1822 let close_layout = close
1823 .as_deref()
1824 .map(|delimiter| layout_delimiter(delimiter, delimiter_rect, stretch_delimiters, ctx));
1825 let open_width = open_layout
1826 .as_ref()
1827 .map(|layout| layout.width + gap)
1828 .unwrap_or(0.0);
1829 let close_width = close_layout
1830 .as_ref()
1831 .map(|layout| layout.width + gap)
1832 .unwrap_or(0.0);
1833 let delimiter_ascent = open_layout
1834 .as_ref()
1835 .into_iter()
1836 .chain(close_layout.as_ref())
1837 .map(|layout| layout.ascent)
1838 .fold(0.0, f32::max);
1839 let delimiter_descent = open_layout
1840 .as_ref()
1841 .into_iter()
1842 .chain(close_layout.as_ref())
1843 .map(|layout| layout.descent)
1844 .fold(0.0, f32::max);
1845 let mut atoms = Vec::new();
1846 if let Some(open) = open_layout {
1847 translate_atoms(&mut atoms, open.atoms, 0.0, 0.0);
1848 }
1849 translate_atoms(&mut atoms, body_layout.atoms, open_width, 0.0);
1850 if let Some(close) = close_layout {
1851 translate_atoms(
1852 &mut atoms,
1853 close.atoms,
1854 open_width + body_layout.width + gap,
1855 0.0,
1856 );
1857 }
1858 MathLayout {
1859 width: open_width + body_layout.width + close_width,
1860 ascent: body_layout.ascent.max(delimiter_ascent),
1861 descent: body_layout.descent.max(delimiter_descent),
1862 atoms,
1863 }
1864}
1865
1866fn delimiter_rect(body: &MathLayout, ctx: LayoutCtx) -> Rect {
1867 let metrics = ctx.metrics();
1868 let overshoot = metrics.delimiter_overshoot();
1869 let top = -body.ascent - overshoot;
1870 let bottom = body.descent + overshoot;
1871 Rect::new(0.0, top, metrics.delimiter_width(), bottom - top)
1872}
1873
1874fn layout_delimiter(delimiter: &str, rect: Rect, stretch: bool, ctx: LayoutCtx) -> MathLayout {
1875 if !stretch || !is_vector_delimiter(delimiter) {
1876 return layout_glyph(delimiter, ctx, FontWeight::Regular, false);
1877 }
1878 if let Some(delimiter) = delimiter
1879 .chars()
1880 .next()
1881 .filter(|_| delimiter.chars().count() == 1)
1882 && let Some(variant) = ctx
1883 .metrics()
1884 .delimiter_variant_for_height(delimiter, rect.h)
1885 && let Some(layout) = layout_delimiter_variant(variant, rect, ctx)
1886 {
1887 return layout;
1888 }
1889 if let Some(delimiter) = delimiter
1890 .chars()
1891 .next()
1892 .filter(|_| delimiter.chars().count() == 1)
1893 && let Some(parts) = ctx.metrics().delimiter_assembly_parts(delimiter)
1894 && let Some(layout) = layout_delimiter_assembly(&parts, rect, ctx)
1895 {
1896 return layout;
1897 }
1898 MathLayout {
1899 width: rect.w,
1900 ascent: -rect.y,
1901 descent: rect.y + rect.h,
1902 atoms: vec![MathAtom::Delimiter {
1903 delimiter: delimiter.to_string(),
1904 rect,
1905 thickness: ctx.metrics().rule_thickness(),
1906 }],
1907 }
1908}
1909
1910fn is_vector_delimiter(delimiter: &str) -> bool {
1911 matches!(
1912 delimiter,
1913 "(" | ")" | "[" | "]" | "{" | "}" | "|" | "‖" | "⟨" | "⟩" | "⌊" | "⌋" | "⌈" | "⌉"
1914 )
1915}
1916
1917fn layout_delimiter_variant(
1918 variant: OpenTypeDelimiterVariant,
1919 target_rect: Rect,
1920 ctx: LayoutCtx,
1921) -> Option<MathLayout> {
1922 let bbox = variant.bbox?;
1923 let metrics = ctx.metrics();
1924 let constants = metrics.font_constants()?;
1925 let scale = metrics.size / constants.units_per_em;
1926 let width = (variant.horizontal_advance as f32 * scale).max(target_rect.w);
1927 let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1928 let glyph_height = view_box.h * scale;
1929 if view_box.w <= 0.0 || glyph_height <= 0.0 {
1930 return None;
1931 }
1932 let target_center_y = target_rect.y + target_rect.h * 0.5;
1933 let glyph_center_y = view_box.y * scale + glyph_height * 0.5;
1934 let glyph_y = target_center_y - glyph_center_y;
1935 let rect = Rect::new(
1936 (width - view_box.w * scale) * 0.5,
1937 glyph_y + view_box.y * scale,
1938 view_box.w * scale,
1939 glyph_height,
1940 );
1941 Some(MathLayout {
1942 width,
1943 ascent: (-rect.y).max(-target_rect.y),
1944 descent: (rect.y + rect.h).max(target_rect.y + target_rect.h),
1945 atoms: vec![MathAtom::GlyphId {
1946 glyph_id: variant.glyph_id,
1947 rect,
1948 view_box,
1949 }],
1950 })
1951}
1952
1953fn layout_delimiter_assembly(
1954 parts: &[OpenTypeDelimiterAssemblyPart],
1955 target_rect: Rect,
1956 ctx: LayoutCtx,
1957) -> Option<MathLayout> {
1958 let metrics = ctx.metrics();
1959 let constants = metrics.font_constants()?;
1960 if constants.units_per_em <= 0.0 {
1961 return None;
1962 }
1963 let scale = metrics.size / constants.units_per_em;
1964 let overlap_units = constants.min_connector_overlap.max(1);
1965 let target_units = target_rect.h / scale;
1966 let source_parts: Vec<OpenTypeDelimiterAssemblyPart> = parts.iter().rev().copied().collect();
1967 let mut assembly = source_parts.clone();
1968 let extender_parts: Vec<OpenTypeDelimiterAssemblyPart> = source_parts
1969 .iter()
1970 .copied()
1971 .filter(|part| part.extender)
1972 .collect();
1973 if extender_parts.is_empty() {
1974 return None;
1975 }
1976
1977 let mut extra_repeats = 0;
1978 while assembly_max_length_units(&assembly, overlap_units) < target_units {
1979 extra_repeats += 1;
1980 assembly = Vec::with_capacity(source_parts.len() + extra_repeats * extender_parts.len());
1981 for part in &source_parts {
1982 assembly.push(*part);
1983 if part.extender {
1984 assembly.extend(std::iter::repeat_n(*part, extra_repeats));
1985 }
1986 }
1987 }
1988
1989 let overlaps = assembly_overlaps_for_target(&assembly, target_units, overlap_units);
1990 let total_units = assembly_raw_advance_units(&assembly) - overlaps.iter().sum::<f32>();
1991 let total_height = total_units * scale;
1992 let target_center_y = target_rect.y + target_rect.h * 0.5;
1993 let top = target_center_y - total_height * 0.5;
1994 let width = assembly
1995 .iter()
1996 .filter_map(|part| {
1997 let bbox = part.bbox?;
1998 Some(
1999 (part.horizontal_advance as f32 * scale)
2000 .max((bbox.x_max - bbox.x_min) as f32 * scale),
2001 )
2002 })
2003 .fold(target_rect.w, f32::max);
2004
2005 let mut cursor_units = 0.0;
2006 let mut atoms = Vec::with_capacity(assembly.len());
2007 for (index, part) in assembly.iter().enumerate() {
2008 let bbox = part.bbox?;
2009 let slot_height = part.full_advance as f32 * scale;
2010 let view_box =
2011 glyph_advance_view_box(bbox, part.horizontal_advance, Some(part.full_advance))?;
2012 let glyph_width = view_box.w * scale;
2013 let glyph_height = view_box.h * scale;
2014 if glyph_width <= 0.0 || glyph_height <= 0.0 || slot_height <= 0.0 {
2015 return None;
2016 }
2017 let rect = Rect::new(
2018 (width - glyph_width) * 0.5,
2019 top + cursor_units * scale,
2020 glyph_width,
2021 slot_height.max(glyph_height),
2022 );
2023 atoms.push(MathAtom::GlyphId {
2024 glyph_id: part.glyph_id,
2025 rect,
2026 view_box,
2027 });
2028 if index + 1 < assembly.len() {
2029 cursor_units += part.full_advance as f32 - overlaps[index];
2030 }
2031 }
2032
2033 Some(MathLayout {
2034 width,
2035 ascent: (-top).max(-target_rect.y),
2036 descent: (top + total_height).max(target_rect.y + target_rect.h),
2037 atoms,
2038 })
2039}
2040
2041fn glyph_advance_view_box(
2042 bbox: OpenTypeGlyphBBox,
2043 horizontal_advance: u16,
2044 vertical_advance: Option<u16>,
2045) -> Option<Rect> {
2046 let x = (bbox.x_min as f32).min(0.0);
2047 let width = (horizontal_advance as f32)
2048 .max(bbox.x_max as f32 - x)
2049 .max((bbox.x_max - bbox.x_min) as f32);
2050 let y = -(bbox.y_max as f32);
2051 let height = vertical_advance
2052 .map(f32::from)
2053 .unwrap_or((bbox.y_max - bbox.y_min) as f32)
2054 .max((bbox.y_max - bbox.y_min) as f32);
2055 (width > 0.0 && height > 0.0).then(|| Rect::new(x, y, width, height))
2056}
2057
2058fn assembly_raw_advance_units(parts: &[OpenTypeDelimiterAssemblyPart]) -> f32 {
2059 parts.iter().map(|part| part.full_advance as f32).sum()
2060}
2061
2062fn assembly_max_length_units(parts: &[OpenTypeDelimiterAssemblyPart], min_overlap: u16) -> f32 {
2063 assembly_raw_advance_units(parts)
2064 - assembly_overlap_limits(parts, min_overlap)
2065 .iter()
2066 .map(|(min, _)| *min)
2067 .sum::<f32>()
2068}
2069
2070fn assembly_overlap_limits(
2071 parts: &[OpenTypeDelimiterAssemblyPart],
2072 min_overlap: u16,
2073) -> Vec<(f32, f32)> {
2074 parts
2075 .windows(2)
2076 .map(|pair| {
2077 let min = min_overlap as f32;
2078 let max = pair[0]
2079 .end_connector_length
2080 .min(pair[1].start_connector_length)
2081 .max(min_overlap) as f32;
2082 (min, max)
2083 })
2084 .collect()
2085}
2086
2087fn assembly_overlaps_for_target(
2088 parts: &[OpenTypeDelimiterAssemblyPart],
2089 target_units: f32,
2090 min_overlap: u16,
2091) -> Vec<f32> {
2092 let limits = assembly_overlap_limits(parts, min_overlap);
2093 if limits.is_empty() {
2094 return Vec::new();
2095 }
2096 let raw = assembly_raw_advance_units(parts);
2097 let min_sum: f32 = limits.iter().map(|(min, _)| *min).sum();
2098 let max_sum: f32 = limits.iter().map(|(_, max)| *max).sum();
2099 let desired_sum = (raw - target_units).clamp(min_sum, max_sum);
2100 let mut overlaps: Vec<f32> = limits.iter().map(|(min, _)| *min).collect();
2101 let mut remaining = desired_sum - min_sum;
2102
2103 while remaining > 0.001 {
2104 let adjustable: Vec<usize> = overlaps
2105 .iter()
2106 .zip(limits.iter())
2107 .enumerate()
2108 .filter_map(|(index, (overlap, (_, max)))| (*overlap < *max - 0.001).then_some(index))
2109 .collect();
2110 if adjustable.is_empty() {
2111 break;
2112 }
2113 let share = remaining / adjustable.len() as f32;
2114 let mut distributed = 0.0;
2115 for index in adjustable {
2116 let capacity = limits[index].1 - overlaps[index];
2117 let add = share.min(capacity);
2118 overlaps[index] += add;
2119 distributed += add;
2120 }
2121 if distributed <= 0.001 {
2122 break;
2123 }
2124 remaining -= distributed;
2125 }
2126
2127 overlaps
2128}
2129
2130fn layout_table(
2131 rows: &[Vec<MathExpr>],
2132 column_alignments: &[MathColumnAlignment],
2133 column_gap: Option<f32>,
2134 row_gap: Option<f32>,
2135 ctx: LayoutCtx,
2136) -> MathLayout {
2137 if rows.is_empty() {
2138 return MathLayout {
2139 width: 0.0,
2140 ascent: 0.0,
2141 descent: 0.0,
2142 atoms: Vec::new(),
2143 };
2144 }
2145 let cell_layouts: Vec<Vec<MathLayout>> = rows
2146 .iter()
2147 .map(|row| row.iter().map(|cell| layout_expr(cell, ctx)).collect())
2148 .collect();
2149 let metrics = ctx.metrics();
2150 let col_count = cell_layouts.iter().map(Vec::len).max().unwrap_or(0);
2151 let mut col_widths = vec![0.0_f32; col_count];
2152 let mut row_ascents = vec![metrics.default_ascent(); rows.len()];
2153 let mut row_descents = vec![metrics.default_descent(); rows.len()];
2154 for (row_index, row) in cell_layouts.iter().enumerate() {
2155 for (col_index, cell) in row.iter().enumerate() {
2156 col_widths[col_index] = col_widths[col_index].max(cell.width);
2157 row_ascents[row_index] = row_ascents[row_index].max(cell.ascent);
2158 row_descents[row_index] = row_descents[row_index].max(cell.descent);
2159 }
2160 }
2161 let col_gap = metrics.table_col_gap(column_gap);
2162 let row_gap = metrics.table_row_gap(row_gap);
2163 let width = col_widths.iter().sum::<f32>() + col_gap * col_count.saturating_sub(1) as f32;
2164 let row_heights: Vec<f32> = row_ascents
2165 .iter()
2166 .zip(row_descents.iter())
2167 .map(|(ascent, descent)| ascent + descent)
2168 .collect();
2169 let height = row_heights.iter().sum::<f32>() + row_gap * rows.len().saturating_sub(1) as f32;
2170 let baseline_origin = height * 0.5 + metrics.math_axis_shift();
2171 let mut atoms = Vec::new();
2172 let mut row_top = 0.0;
2173 for (row_index, row) in cell_layouts.into_iter().enumerate() {
2174 let row_baseline = row_top + row_ascents[row_index];
2175 let mut col_left = 0.0;
2176 for (col_index, cell) in row.into_iter().enumerate() {
2177 let col_extra = col_widths[col_index] - cell.width;
2178 let align = column_alignments
2179 .get(col_index)
2180 .copied()
2181 .unwrap_or_default();
2182 let cell_x = col_left
2183 + match align {
2184 MathColumnAlignment::Left => 0.0,
2185 MathColumnAlignment::Center => col_extra * 0.5,
2186 MathColumnAlignment::Right => col_extra,
2187 };
2188 translate_atoms(
2189 &mut atoms,
2190 cell.atoms,
2191 cell_x,
2192 row_baseline - baseline_origin,
2193 );
2194 col_left += col_widths[col_index] + col_gap;
2195 }
2196 row_top += row_heights[row_index] + row_gap;
2197 }
2198 MathLayout {
2199 width,
2200 ascent: baseline_origin,
2201 descent: height - baseline_origin,
2202 atoms,
2203 }
2204}
2205
2206fn translate_atoms(out: &mut Vec<MathAtom>, atoms: Vec<MathAtom>, dx: f32, dy: f32) {
2207 out.extend(atoms.into_iter().map(|atom| match atom {
2208 MathAtom::Glyph {
2209 text,
2210 x,
2211 y_baseline,
2212 size,
2213 weight,
2214 italic,
2215 } => MathAtom::Glyph {
2216 text,
2217 x: x + dx,
2218 y_baseline: y_baseline + dy,
2219 size,
2220 weight,
2221 italic,
2222 },
2223 MathAtom::GlyphId {
2224 glyph_id,
2225 rect,
2226 view_box,
2227 } => MathAtom::GlyphId {
2228 glyph_id,
2229 rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2230 view_box,
2231 },
2232 MathAtom::Rule { rect } => MathAtom::Rule {
2233 rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2234 },
2235 MathAtom::Radical { points, thickness } => MathAtom::Radical {
2236 points: points.map(|[x, y]| [x + dx, y + dy]),
2237 thickness,
2238 },
2239 MathAtom::Delimiter {
2240 delimiter,
2241 rect,
2242 thickness,
2243 } => MathAtom::Delimiter {
2244 delimiter,
2245 rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2246 thickness,
2247 },
2248 }));
2249}
2250
2251pub fn parse_tex(input: &str) -> Result<MathExpr, MathParseError> {
2262 let mut parser = TexParser::new(input);
2263 let expr = parser.parse_row(None)?;
2264 parser.skip_ws();
2265 if parser.peek().is_some() {
2266 return Err(parser.error("unexpected trailing input"));
2267 }
2268 Ok(expr)
2269}
2270
2271pub fn parse_tex_with_source_ranges(input: &str) -> Result<MathExpr, MathParseError> {
2277 let mut parser = TexParser::with_source_ranges(input);
2278 let expr = parser.parse_row(None)?;
2279 parser.skip_ws();
2280 if parser.peek().is_some() {
2281 return Err(parser.error("unexpected trailing input"));
2282 }
2283 Ok(expr)
2284}
2285
2286pub fn parse_mathml(input: &str) -> Result<MathExpr, MathParseError> {
2290 Ok(parse_mathml_with_display(input)?.0)
2291}
2292
2293pub fn parse_mathml_with_display(input: &str) -> Result<(MathExpr, MathDisplay), MathParseError> {
2302 let doc = roxmltree::Document::parse(input).map_err(|err| {
2303 let pos = err.pos();
2304 MathParseError {
2305 message: err.to_string(),
2306 byte: text_pos_to_byte(input, pos.row, pos.col),
2307 }
2308 })?;
2309 let root = doc.root_element();
2310 let display = match root.attribute("display") {
2311 Some("block") => MathDisplay::Block,
2312 _ => MathDisplay::Inline,
2313 };
2314 let expr = parse_mathml_node(root)?;
2315 Ok((expr, display))
2316}
2317
2318#[derive(Clone, Debug, PartialEq, Eq)]
2321pub struct MathParseError {
2322 pub message: String,
2324 pub byte: usize,
2326}
2327
2328fn parse_mathml_node(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2329 let name = node.tag_name().name();
2330 match name {
2331 "math" | "mrow" => Ok(MathExpr::row(parse_mathml_children(node)?)),
2332 "mi" => Ok(MathExpr::Identifier(normalized_node_text(node))),
2333 "mn" => Ok(MathExpr::Number(normalized_node_text(node))),
2334 "mo" => parse_mathml_operator(node),
2335 "mtext" => Ok(MathExpr::Text(normalized_node_text(node))),
2336 "mspace" => Ok(MathExpr::Space(parse_mathml_space(node))),
2337 "mfrac" => {
2338 let children = mathml_element_children(node);
2339 require_mathml_arity(node, &children, 2)?;
2340 Ok(MathExpr::Fraction {
2341 numerator: Arc::new(parse_mathml_node(children[0])?),
2342 denominator: Arc::new(parse_mathml_node(children[1])?),
2343 })
2344 }
2345 "msqrt" => Ok(MathExpr::Sqrt(Arc::new(MathExpr::row(
2346 parse_mathml_children(node)?,
2347 )))),
2348 "mroot" => {
2349 let children = mathml_element_children(node);
2350 require_mathml_arity(node, &children, 2)?;
2351 Ok(MathExpr::Root {
2352 base: Arc::new(parse_mathml_node(children[0])?),
2353 index: Arc::new(parse_mathml_node(children[1])?),
2354 })
2355 }
2356 "msub" => parse_mathml_scripts(node, true, false),
2357 "msup" => parse_mathml_scripts(node, false, true),
2358 "msubsup" => parse_mathml_scripts(node, true, true),
2359 "munder" => parse_mathml_under_over(node, true, false),
2360 "mover" if mathml_bool_attr(node.attribute("accent")) => parse_mathml_accent(node),
2361 "mover" => parse_mathml_under_over(node, false, true),
2362 "munderover" => parse_mathml_under_over(node, true, true),
2363 "mfenced" => parse_mathml_fenced(node),
2364 "semantics" => parse_mathml_semantics(node),
2365 "mtable" => parse_mathml_table(node),
2366 "mtr" => Ok(MathExpr::row(
2367 mathml_element_children(node)
2368 .into_iter()
2369 .map(parse_mathml_node)
2370 .collect::<Result<Vec<_>, _>>()?,
2371 )),
2372 "mtd" => Ok(MathExpr::row(parse_mathml_children(node)?)),
2373 unsupported => Ok(MathExpr::Error(format!(
2374 "unsupported MathML element <{unsupported}>"
2375 ))),
2376 }
2377}
2378
2379fn parse_mathml_children(node: roxmltree::Node<'_, '_>) -> Result<Vec<MathExpr>, MathParseError> {
2380 mathml_element_children(node)
2381 .into_iter()
2382 .map(parse_mathml_node)
2383 .collect()
2384}
2385
2386fn parse_mathml_operator(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2387 let operator = normalized_node_text(node);
2388 let lspace = node.attribute("lspace").and_then(parse_em_length);
2389 let rspace = node.attribute("rspace").and_then(parse_em_length);
2390 let large_operator = node.attribute("largeop").map(mathml_bool_attr_value);
2391 let movable_limits = node.attribute("movablelimits").map(mathml_bool_attr_value);
2392 if lspace.is_none() && rspace.is_none() && large_operator.is_none() && movable_limits.is_none()
2393 {
2394 return Ok(MathExpr::Operator(operator));
2395 }
2396 Ok(MathExpr::OperatorWithMetadata {
2397 text: operator,
2398 lspace,
2399 rspace,
2400 large_operator,
2401 movable_limits,
2402 })
2403}
2404
2405fn parse_mathml_semantics(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2406 let children = mathml_element_children(node);
2407 let Some(presentation) = children
2408 .into_iter()
2409 .find(|child| !matches!(child.tag_name().name(), "annotation" | "annotation-xml"))
2410 else {
2411 return Err(mathml_error_at(
2412 node,
2413 "<semantics> expected a presentation child".to_string(),
2414 ));
2415 };
2416 parse_mathml_node(presentation)
2417}
2418
2419fn mathml_element_children<'a, 'input>(
2420 node: roxmltree::Node<'a, 'input>,
2421) -> Vec<roxmltree::Node<'a, 'input>> {
2422 node.children()
2423 .filter(roxmltree::Node::is_element)
2424 .collect()
2425}
2426
2427fn require_mathml_arity(
2428 node: roxmltree::Node<'_, '_>,
2429 children: &[roxmltree::Node<'_, '_>],
2430 expected: usize,
2431) -> Result<(), MathParseError> {
2432 if children.len() == expected {
2433 Ok(())
2434 } else {
2435 Err(mathml_error_at(
2436 node,
2437 format!(
2438 "<{}> expected {expected} element children, got {}",
2439 node.tag_name().name(),
2440 children.len()
2441 ),
2442 ))
2443 }
2444}
2445
2446fn parse_mathml_scripts(
2447 node: roxmltree::Node<'_, '_>,
2448 has_sub: bool,
2449 has_sup: bool,
2450) -> Result<MathExpr, MathParseError> {
2451 let children = mathml_element_children(node);
2452 let expected = 1 + usize::from(has_sub) + usize::from(has_sup);
2453 require_mathml_arity(node, &children, expected)?;
2454 let base = Arc::new(parse_mathml_node(children[0])?);
2455 let sub = has_sub.then(|| {
2456 let index = 1;
2457 parse_mathml_node(children[index]).map(Arc::new)
2458 });
2459 let sup = has_sup.then(|| {
2460 let index = if has_sub { 2 } else { 1 };
2461 parse_mathml_node(children[index]).map(Arc::new)
2462 });
2463 Ok(MathExpr::Scripts {
2464 base,
2465 sub: sub.transpose()?,
2466 sup: sup.transpose()?,
2467 })
2468}
2469
2470fn parse_mathml_under_over(
2471 node: roxmltree::Node<'_, '_>,
2472 has_under: bool,
2473 has_over: bool,
2474) -> Result<MathExpr, MathParseError> {
2475 let children = mathml_element_children(node);
2476 let expected = 1 + usize::from(has_under) + usize::from(has_over);
2477 require_mathml_arity(node, &children, expected)?;
2478 let base = Arc::new(parse_mathml_node(children[0])?);
2479 let under = has_under.then(|| {
2480 let index = 1;
2481 parse_mathml_node(children[index]).map(Arc::new)
2482 });
2483 let over = has_over.then(|| {
2484 let index = if has_under { 2 } else { 1 };
2485 parse_mathml_node(children[index]).map(Arc::new)
2486 });
2487 Ok(MathExpr::UnderOver {
2488 base,
2489 under: under.transpose()?,
2490 over: over.transpose()?,
2491 })
2492}
2493
2494fn parse_mathml_accent(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2495 let children = mathml_element_children(node);
2496 require_mathml_arity(node, &children, 2)?;
2497 let accent = parse_mathml_node(children[1])?;
2498 let stretch =
2499 mathml_bool_attr(children[1].attribute("stretchy")) || is_overline_accent(&accent);
2500 Ok(MathExpr::Accent {
2501 base: Arc::new(parse_mathml_node(children[0])?),
2502 accent: Arc::new(accent),
2503 stretch,
2504 })
2505}
2506
2507fn mathml_bool_attr(value: Option<&str>) -> bool {
2508 value.is_some_and(mathml_bool_attr_value)
2509}
2510
2511fn mathml_bool_attr_value(value: &str) -> bool {
2512 matches!(value.trim(), "true" | "1")
2513}
2514
2515fn parse_mathml_table(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2516 let mut rows = Vec::new();
2517 for row_node in mathml_element_children(node) {
2518 if !matches!(row_node.tag_name().name(), "mtr" | "mlabeledtr") {
2519 return Err(mathml_error_at(
2520 row_node,
2521 format!(
2522 "<mtable> expected row element children, got <{}>",
2523 row_node.tag_name().name()
2524 ),
2525 ));
2526 }
2527 let mut row = Vec::new();
2528 for cell_node in mathml_element_children(row_node) {
2529 require_mathml_tag(cell_node, "mtd")?;
2530 row.push(MathExpr::row(parse_mathml_children(cell_node)?));
2531 }
2532 rows.push(row);
2533 }
2534 let column_alignments = parse_mathml_column_alignments(node.attribute("columnalign"))?;
2535 let column_gap = parse_mathml_table_spacing(node.attribute("columnspacing"))?;
2536 let row_gap = parse_mathml_table_spacing(node.attribute("rowspacing"))?;
2537 Ok(MathExpr::Table {
2538 rows,
2539 column_alignments,
2540 column_gap,
2541 row_gap,
2542 })
2543}
2544
2545fn parse_mathml_column_alignments(
2546 value: Option<&str>,
2547) -> Result<Vec<MathColumnAlignment>, MathParseError> {
2548 let Some(value) = value else {
2549 return Ok(Vec::new());
2550 };
2551 value
2552 .split_whitespace()
2553 .map(|token| match token {
2554 "left" => Ok(MathColumnAlignment::Left),
2555 "center" => Ok(MathColumnAlignment::Center),
2556 "right" => Ok(MathColumnAlignment::Right),
2557 "decimal" => Ok(MathColumnAlignment::Right),
2558 other => Err(MathParseError {
2559 message: format!("unsupported MathML columnalign value {other:?}"),
2560 byte: 0,
2561 }),
2562 })
2563 .collect()
2564}
2565
2566fn parse_mathml_table_spacing(value: Option<&str>) -> Result<Option<f32>, MathParseError> {
2567 let Some(value) = value else {
2568 return Ok(None);
2569 };
2570 let Some(first) = value.split_whitespace().next() else {
2571 return Ok(None);
2572 };
2573 parse_mathml_em_length(first).map(Some)
2574}
2575
2576fn parse_mathml_em_length(value: &str) -> Result<f32, MathParseError> {
2577 let number = value.strip_suffix("em").unwrap_or(value);
2578 let parsed = number.parse::<f32>().map_err(|_| MathParseError {
2579 message: format!("unsupported MathML table spacing value {value:?}"),
2580 byte: 0,
2581 })?;
2582 if parsed.is_sign_negative() {
2583 return Err(MathParseError {
2584 message: format!("negative MathML table spacing value {value:?}"),
2585 byte: 0,
2586 });
2587 }
2588 Ok(parsed)
2589}
2590
2591fn parse_mathml_fenced(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2592 let open = parse_fence_attr(node.attribute("open").unwrap_or("("));
2593 let close = parse_fence_attr(node.attribute("close").unwrap_or(")"));
2594 let separator = match node.attribute("separators") {
2595 Some(value) => value
2596 .chars()
2597 .find(|ch| !ch.is_whitespace())
2598 .map(|ch| ch.to_string()),
2599 None => Some(",".to_string()),
2600 };
2601 let children = parse_mathml_children(node)?;
2602 let mut body = Vec::new();
2603 for (index, child) in children.into_iter().enumerate() {
2604 if index > 0
2605 && let Some(separator) = &separator
2606 {
2607 body.push(MathExpr::Operator(separator.clone()));
2608 }
2609 body.push(child);
2610 }
2611 Ok(MathExpr::Fenced {
2612 open,
2613 close,
2614 body: Arc::new(MathExpr::row(body)),
2615 })
2616}
2617
2618fn parse_fence_attr(value: &str) -> Option<String> {
2619 let value = value.trim();
2620 if value.is_empty() || value == "." {
2621 None
2622 } else {
2623 Some(value.to_string())
2624 }
2625}
2626
2627fn require_mathml_tag(node: roxmltree::Node<'_, '_>, expected: &str) -> Result<(), MathParseError> {
2628 if node.tag_name().name() == expected {
2629 Ok(())
2630 } else {
2631 Err(mathml_error_at(
2632 node,
2633 format!(
2634 "expected <{expected}> element, got <{}>",
2635 node.tag_name().name()
2636 ),
2637 ))
2638 }
2639}
2640
2641fn normalized_node_text(node: roxmltree::Node<'_, '_>) -> String {
2642 node.descendants()
2643 .filter(roxmltree::Node::is_text)
2644 .filter_map(|n| n.text())
2645 .collect::<String>()
2646 .split_whitespace()
2647 .collect::<Vec<_>>()
2648 .join(" ")
2649}
2650
2651fn parse_mathml_space(node: roxmltree::Node<'_, '_>) -> f32 {
2652 node.attribute("width")
2653 .and_then(parse_em_length)
2654 .unwrap_or(0.3)
2655}
2656
2657fn parse_em_length(s: &str) -> Option<f32> {
2658 let trimmed = s.trim();
2659 if let Some(number) = trimmed.strip_suffix("em") {
2660 return number.trim().parse().ok();
2661 }
2662 if let Some(number) = trimmed.strip_suffix("px") {
2663 return number.trim().parse::<f32>().ok().map(|px| px / 16.0);
2664 }
2665 trimmed.parse().ok()
2666}
2667
2668fn mathml_error_at(node: roxmltree::Node<'_, '_>, message: String) -> MathParseError {
2669 MathParseError {
2670 message,
2671 byte: node.range().start,
2672 }
2673}
2674
2675fn text_pos_to_byte(input: &str, row: u32, col: u32) -> usize {
2676 let mut current_row = 1;
2677 let mut current_col = 1;
2678 for (byte, ch) in input.char_indices() {
2679 if current_row == row && current_col == col {
2680 return byte;
2681 }
2682 if ch == '\n' {
2683 current_row += 1;
2684 current_col = 1;
2685 } else {
2686 current_col += 1;
2687 }
2688 }
2689 input.len()
2690}
2691
2692struct TexParser<'a> {
2693 input: &'a str,
2694 pos: usize,
2695 source_ranges: bool,
2696}
2697
2698impl<'a> TexParser<'a> {
2699 fn new(input: &'a str) -> Self {
2700 Self {
2701 input,
2702 pos: 0,
2703 source_ranges: false,
2704 }
2705 }
2706
2707 fn with_source_ranges(input: &'a str) -> Self {
2708 Self {
2709 input,
2710 pos: 0,
2711 source_ranges: true,
2712 }
2713 }
2714
2715 fn source_wrap(&self, start: usize, expr: MathExpr) -> MathExpr {
2716 if self.source_ranges {
2717 MathExpr::Source {
2718 source: start..self.pos,
2719 body: Arc::new(expr),
2720 }
2721 } else {
2722 expr
2723 }
2724 }
2725
2726 fn parse_row(&mut self, until: Option<char>) -> Result<MathExpr, MathParseError> {
2727 let start = self.pos;
2728 let mut items = Vec::new();
2729 loop {
2730 self.skip_ws();
2731 if self.starts_with_command("right") {
2732 return Err(self.error("unexpected \\right"));
2733 }
2734 match self.peek() {
2735 None => {
2736 if until.is_some() {
2737 return Err(self.error("unclosed group"));
2738 }
2739 break;
2740 }
2741 Some(ch) if Some(ch) == until => {
2742 self.bump();
2743 break;
2744 }
2745 Some('}') => return Err(self.error("unexpected closing brace")),
2746 _ => {
2747 let atom = self.parse_atom_with_scripts()?;
2748 items.push(atom);
2749 }
2750 }
2751 }
2752 let expr = MathExpr::row(items);
2753 Ok(self.source_wrap(start, expr))
2754 }
2755
2756 fn parse_row_until_right(&mut self) -> Result<MathExpr, MathParseError> {
2757 let start = self.pos;
2758 let mut items = Vec::new();
2759 loop {
2760 self.skip_ws();
2761 if self.peek().is_none() {
2762 return Err(self.error("unclosed \\left"));
2763 }
2764 if self.starts_with_command("right") {
2765 break;
2766 }
2767 if self.peek() == Some('}') {
2768 return Err(self.error("unexpected closing brace"));
2769 }
2770 let atom = self.parse_atom_with_scripts()?;
2771 items.push(atom);
2772 }
2773 let expr = MathExpr::row(items);
2774 Ok(self.source_wrap(start, expr))
2775 }
2776
2777 fn parse_table_environment(
2778 &mut self,
2779 env: &str,
2780 column_alignments: Vec<MathColumnAlignment>,
2781 column_gap: Option<f32>,
2782 row_gap: Option<f32>,
2783 ) -> Result<MathExpr, MathParseError> {
2784 let mut rows = Vec::new();
2785 let mut row = Vec::new();
2786 let mut cell = Vec::new();
2787
2788 loop {
2789 self.skip_ws();
2790 if self.peek().is_none() {
2791 return Err(self.error(&format!("unclosed \\begin{{{env}}}")));
2792 }
2793 if self.starts_with_command("end") {
2794 self.consume_environment_end(env)?;
2795 if !row.is_empty() || !cell.is_empty() || rows.is_empty() {
2796 row.push(MathExpr::row(std::mem::take(&mut cell)));
2797 rows.push(row);
2798 }
2799 break;
2800 }
2801 if self.peek() == Some('&') {
2802 self.bump();
2803 row.push(MathExpr::row(std::mem::take(&mut cell)));
2804 continue;
2805 }
2806 if self.starts_with_row_separator() {
2807 self.consume_row_separator()?;
2808 row.push(MathExpr::row(std::mem::take(&mut cell)));
2809 rows.push(std::mem::take(&mut row));
2810 continue;
2811 }
2812
2813 cell.push(self.parse_atom_with_scripts()?);
2814 }
2815
2816 self.validate_tex_table_shape(env, &rows, &column_alignments)?;
2817
2818 let table = MathExpr::Table {
2819 rows,
2820 column_alignments,
2821 column_gap,
2822 row_gap,
2823 };
2824 Ok(match env {
2825 "matrix" | "array" | "aligned" | "align" => table,
2826 "pmatrix" => MathExpr::Fenced {
2827 open: Some("(".into()),
2828 close: Some(")".into()),
2829 body: Arc::new(table),
2830 },
2831 "bmatrix" => MathExpr::Fenced {
2832 open: Some("[".into()),
2833 close: Some("]".into()),
2834 body: Arc::new(table),
2835 },
2836 "Bmatrix" => MathExpr::Fenced {
2837 open: Some("{".into()),
2838 close: Some("}".into()),
2839 body: Arc::new(table),
2840 },
2841 "vmatrix" => MathExpr::Fenced {
2842 open: Some("|".into()),
2843 close: Some("|".into()),
2844 body: Arc::new(table),
2845 },
2846 "Vmatrix" => MathExpr::Fenced {
2847 open: Some("‖".into()),
2848 close: Some("‖".into()),
2849 body: Arc::new(table),
2850 },
2851 "cases" => MathExpr::Fenced {
2852 open: Some("{".into()),
2853 close: None,
2854 body: Arc::new(table),
2855 },
2856 _ => return Err(self.error(&format!("unsupported math environment {env}"))),
2857 })
2858 }
2859
2860 fn validate_tex_table_shape(
2861 &self,
2862 env: &str,
2863 rows: &[Vec<MathExpr>],
2864 column_alignments: &[MathColumnAlignment],
2865 ) -> Result<(), MathParseError> {
2866 let Some(first_row) = rows.first() else {
2867 return Ok(());
2868 };
2869 let expected_cols = first_row.len();
2870 for (row_index, row) in rows.iter().enumerate().skip(1) {
2871 if row.len() != expected_cols {
2872 return Err(self.error(&format!(
2873 "inconsistent column count in {env}: row {} has {}, expected {expected_cols}",
2874 row_index + 1,
2875 row.len()
2876 )));
2877 }
2878 }
2879 if !column_alignments.is_empty() && column_alignments.len() != expected_cols {
2880 return Err(self.error(&format!(
2881 "{env} alignment spec has {} columns, but table has {expected_cols}",
2882 column_alignments.len()
2883 )));
2884 }
2885 Ok(())
2886 }
2887
2888 fn parse_atom_with_scripts(&mut self) -> Result<MathExpr, MathParseError> {
2889 let start = self.pos;
2890 let mut base = self.parse_atom()?;
2891 let mut sub = None;
2892 let mut sup = None;
2893 loop {
2894 self.skip_ws();
2895 match self.peek() {
2896 Some('_') => {
2897 if sub.is_some() {
2902 return Err(self.error(
2903 "double subscript: brace the first script, e.g. `x_{1_2}` or `{x_1}_2`",
2904 ));
2905 }
2906 self.bump();
2907 sub = Some(Arc::new(self.parse_script_arg()?));
2908 }
2909 Some('^') => {
2910 if sup.is_some() {
2911 return Err(self.error("double superscript: brace the first script, e.g. `x^{1^2}` or `{x^1}^2`"));
2912 }
2913 self.bump();
2914 sup = Some(Arc::new(self.parse_script_arg()?));
2915 }
2916 _ => break,
2917 }
2918 }
2919 if sub.is_some() || sup.is_some() {
2920 base = MathExpr::Scripts {
2921 base: Arc::new(base),
2922 sub,
2923 sup,
2924 };
2925 base = self.source_wrap(start, base);
2926 }
2927 Ok(base)
2928 }
2929
2930 fn parse_script_arg(&mut self) -> Result<MathExpr, MathParseError> {
2931 self.skip_ws();
2932 if self.peek() == Some('{') {
2933 self.bump();
2934 self.parse_row(Some('}'))
2935 } else {
2936 self.parse_atom()
2937 }
2938 }
2939
2940 fn parse_atom(&mut self) -> Result<MathExpr, MathParseError> {
2941 self.skip_ws();
2942 let start = self.pos;
2943 match self.peek() {
2944 Some('{') => {
2945 self.bump();
2946 let expr = self.parse_row(Some('}'))?;
2947 Ok(self.source_wrap(start, expr))
2948 }
2949 Some('\\') => self.parse_command(),
2950 Some(ch) if ch.is_ascii_digit() => {
2951 let text = self.take_while(|c| c.is_ascii_digit() || c == '.');
2952 Ok(self.source_wrap(start, MathExpr::Number(text)))
2953 }
2954 Some(ch) if ch.is_alphabetic() => {
2955 self.bump();
2956 Ok(self.source_wrap(start, MathExpr::Identifier(ch.to_string())))
2957 }
2958 Some(ch) => {
2959 self.bump();
2960 let expr = if ch.is_whitespace() {
2961 MathExpr::Space(0.3)
2962 } else {
2963 MathExpr::Operator(ch.to_string())
2964 };
2965 Ok(self.source_wrap(start, expr))
2966 }
2967 None => Err(self.error("expected math atom")),
2968 }
2969 }
2970
2971 fn parse_command(&mut self) -> Result<MathExpr, MathParseError> {
2972 let start = self.pos;
2973 let expr = self.parse_command_unwrapped()?;
2974 Ok(self.source_wrap(start, expr))
2975 }
2976
2977 fn parse_command_unwrapped(&mut self) -> Result<MathExpr, MathParseError> {
2978 self.expect('\\')?;
2979 let name = self.take_while(|c| c.is_ascii_alphabetic());
2980 if name.is_empty() {
2981 let escaped = self
2982 .bump()
2983 .ok_or_else(|| self.error("expected escaped character"))?;
2984 return Ok(match escaped {
2985 ',' => MathExpr::Space(THIN_MATH_SPACE_EM),
2986 ':' => MathExpr::Space(MEDIUM_MATH_SPACE_EM),
2987 ';' => MathExpr::Space(THICK_MATH_SPACE_EM),
2988 '!' => MathExpr::Space(-THIN_MATH_SPACE_EM),
2989 ' ' => MathExpr::Space(MEDIUM_MATH_SPACE_EM),
2990 _ => MathExpr::Operator(escaped.to_string()),
2991 });
2992 }
2993 match name.as_str() {
2994 "frac" | "tfrac" | "dfrac" => {
2995 let numerator = Arc::new(self.parse_required_group()?);
2996 let denominator = Arc::new(self.parse_required_group()?);
2997 Ok(MathExpr::Fraction {
2998 numerator,
2999 denominator,
3000 })
3001 }
3002 "binom" => {
3003 let numerator = self.parse_required_group()?;
3004 let denominator = self.parse_required_group()?;
3005 Ok(MathExpr::Fenced {
3006 open: Some("(".into()),
3007 close: Some(")".into()),
3008 body: Arc::new(MathExpr::Table {
3009 rows: vec![vec![numerator], vec![denominator]],
3010 column_alignments: Vec::new(),
3011 column_gap: None,
3012 row_gap: None,
3013 }),
3014 })
3015 }
3016 "sqrt" => {
3017 let index = self.parse_optional_bracket_group()?;
3018 let base = Arc::new(self.parse_required_group()?);
3019 Ok(match index {
3020 Some(index) => MathExpr::Root {
3021 base,
3022 index: Arc::new(index),
3023 },
3024 None => MathExpr::Sqrt(base),
3025 })
3026 }
3027 "hat" | "widehat" => Ok(MathExpr::Accent {
3028 base: Arc::new(self.parse_required_group()?),
3029 accent: Arc::new(MathExpr::Operator("ˆ".into())),
3030 stretch: false,
3031 }),
3032 "bar" => Ok(MathExpr::Accent {
3033 base: Arc::new(self.parse_required_group()?),
3034 accent: Arc::new(MathExpr::Operator("¯".into())),
3035 stretch: false,
3036 }),
3037 "overline" => Ok(MathExpr::Accent {
3038 base: Arc::new(self.parse_required_group()?),
3039 accent: Arc::new(MathExpr::Operator("‾".into())),
3040 stretch: true,
3041 }),
3042 "vec" => Ok(MathExpr::Accent {
3043 base: Arc::new(self.parse_required_group()?),
3044 accent: Arc::new(MathExpr::Operator("→".into())),
3045 stretch: false,
3046 }),
3047 "tilde" | "widetilde" => Ok(MathExpr::Accent {
3048 base: Arc::new(self.parse_required_group()?),
3049 accent: Arc::new(MathExpr::Operator("˜".into())),
3050 stretch: false,
3051 }),
3052 "left" => {
3053 let open = self.parse_delimiter()?;
3054 let body = Arc::new(self.parse_row_until_right()?);
3055 self.consume_command("right")?;
3056 let close = self.parse_delimiter()?;
3057 Ok(MathExpr::Fenced { open, close, body })
3058 }
3059 "right" => Err(self.error("unexpected \\right")),
3060 "begin" => {
3061 let env = self.parse_environment_name()?;
3062 match env.as_str() {
3063 "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix"
3064 | "cases" | "aligned" | "align" => {
3065 let options = default_tex_table_options(&env);
3066 self.parse_table_environment(
3067 &env,
3068 options.column_alignments,
3069 options.column_gap,
3070 options.row_gap,
3071 )
3072 }
3073 "array" => {
3074 let column_alignments = self.parse_array_column_alignments()?;
3075 self.parse_table_environment(&env, column_alignments, None, None)
3076 }
3077 _ => Err(self.error(&format!("unsupported math environment {env}"))),
3078 }
3079 }
3080 "end" => Err(self.error("unexpected \\end")),
3081 "text" | "mathrm" | "operatorname" => Ok(MathExpr::Text(self.parse_text_group()?)),
3082 "mathbf" | "boldsymbol" | "mathcal" => self.parse_required_group(),
3083 "mathbb" => {
3084 let expr = self.parse_required_group()?;
3085 Ok(map_mathbb_expr(expr))
3086 }
3087 "alpha" => Ok(MathExpr::Identifier("α".into())),
3089 "beta" => Ok(MathExpr::Identifier("β".into())),
3090 "gamma" => Ok(MathExpr::Identifier("γ".into())),
3091 "delta" => Ok(MathExpr::Identifier("δ".into())),
3092 "varepsilon" | "epsilon" => Ok(MathExpr::Identifier("ε".into())),
3093 "zeta" => Ok(MathExpr::Identifier("ζ".into())),
3094 "eta" => Ok(MathExpr::Identifier("η".into())),
3095 "theta" => Ok(MathExpr::Identifier("θ".into())),
3096 "vartheta" => Ok(MathExpr::Identifier("ϑ".into())),
3097 "iota" => Ok(MathExpr::Identifier("ι".into())),
3098 "kappa" => Ok(MathExpr::Identifier("κ".into())),
3099 "varkappa" => Ok(MathExpr::Identifier("ϰ".into())),
3100 "lambda" => Ok(MathExpr::Identifier("λ".into())),
3101 "mu" => Ok(MathExpr::Identifier("μ".into())),
3102 "nu" => Ok(MathExpr::Identifier("ν".into())),
3103 "xi" => Ok(MathExpr::Identifier("ξ".into())),
3104 "pi" => Ok(MathExpr::Identifier("π".into())),
3105 "varpi" => Ok(MathExpr::Identifier("ϖ".into())),
3106 "rho" => Ok(MathExpr::Identifier("ρ".into())),
3107 "varrho" => Ok(MathExpr::Identifier("ϱ".into())),
3108 "sigma" => Ok(MathExpr::Identifier("σ".into())),
3109 "varsigma" => Ok(MathExpr::Identifier("ς".into())),
3110 "tau" => Ok(MathExpr::Identifier("τ".into())),
3111 "upsilon" => Ok(MathExpr::Identifier("υ".into())),
3112 "phi" | "varphi" => Ok(MathExpr::Identifier("φ".into())),
3113 "chi" => Ok(MathExpr::Identifier("χ".into())),
3114 "psi" => Ok(MathExpr::Identifier("ψ".into())),
3115 "omega" => Ok(MathExpr::Identifier("ω".into())),
3116 "Gamma" => Ok(MathExpr::Identifier("Γ".into())),
3118 "Delta" => Ok(MathExpr::Identifier("Δ".into())),
3119 "Theta" => Ok(MathExpr::Identifier("Θ".into())),
3120 "Lambda" => Ok(MathExpr::Identifier("Λ".into())),
3121 "Xi" => Ok(MathExpr::Identifier("Ξ".into())),
3122 "Pi" => Ok(MathExpr::Identifier("Π".into())),
3123 "Sigma" => Ok(MathExpr::Identifier("Σ".into())),
3124 "Upsilon" => Ok(MathExpr::Identifier("Υ".into())),
3125 "Phi" => Ok(MathExpr::Identifier("Φ".into())),
3126 "Psi" => Ok(MathExpr::Identifier("Ψ".into())),
3127 "Omega" => Ok(MathExpr::Identifier("Ω".into())),
3128 "partial" => Ok(MathExpr::Identifier("∂".into())),
3130 "infty" => Ok(MathExpr::Identifier("∞".into())),
3131 "hbar" => Ok(MathExpr::Identifier("ℏ".into())),
3132 "Re" => Ok(MathExpr::Identifier("ℜ".into())),
3133 "Im" => Ok(MathExpr::Identifier("ℑ".into())),
3134 "aleph" => Ok(MathExpr::Identifier("ℵ".into())),
3135 "beth" => Ok(MathExpr::Identifier("ℶ".into())),
3136 "wp" => Ok(MathExpr::Identifier("℘".into())),
3137 "ell" => Ok(MathExpr::Identifier("ℓ".into())),
3138 "emptyset" | "varnothing" => Ok(MathExpr::Identifier("∅".into())),
3139 "triangle" => Ok(MathExpr::Identifier("△".into())),
3140 "square" => Ok(MathExpr::Identifier("□".into())),
3141 "flat" => Ok(MathExpr::Identifier("♭".into())),
3142 "sharp" => Ok(MathExpr::Identifier("♯".into())),
3143 "natural" => Ok(MathExpr::Identifier("♮".into())),
3144 "pm" => Ok(MathExpr::Operator("±".into())),
3146 "mp" => Ok(MathExpr::Operator("∓".into())),
3147 "cdot" => Ok(MathExpr::Operator("·".into())),
3148 "times" => Ok(MathExpr::Operator("×".into())),
3149 "div" => Ok(MathExpr::Operator("÷".into())),
3150 "cup" => Ok(MathExpr::Operator("∪".into())),
3151 "cap" => Ok(MathExpr::Operator("∩".into())),
3152 "wedge" | "land" => Ok(MathExpr::Operator("∧".into())),
3153 "vee" | "lor" => Ok(MathExpr::Operator("∨".into())),
3154 "oplus" => Ok(MathExpr::Operator("⊕".into())),
3155 "ominus" => Ok(MathExpr::Operator("⊖".into())),
3156 "otimes" => Ok(MathExpr::Operator("⊗".into())),
3157 "oslash" => Ok(MathExpr::Operator("⊘".into())),
3158 "odot" => Ok(MathExpr::Operator("⊙".into())),
3159 "circ" => Ok(MathExpr::Operator("∘".into())),
3160 "bullet" => Ok(MathExpr::Operator("•".into())),
3161 "star" => Ok(MathExpr::Operator("⋆".into())),
3162 "ast" => Ok(MathExpr::Operator("∗".into())),
3163 "sqcup" => Ok(MathExpr::Operator("⊔".into())),
3164 "sqcap" => Ok(MathExpr::Operator("⊓".into())),
3165 "amalg" => Ok(MathExpr::Operator("⨿".into())),
3166 "wr" => Ok(MathExpr::Operator("≀".into())),
3167 "triangleleft" => Ok(MathExpr::Operator("◁".into())),
3168 "triangleright" => Ok(MathExpr::Operator("▷".into())),
3169 "diamond" => Ok(MathExpr::Operator("⋄".into())),
3170 "setminus" | "smallsetminus" => Ok(MathExpr::Operator("∖".into())),
3171 "approx" => Ok(MathExpr::Operator("≈".into())),
3173 "sim" => Ok(MathExpr::Operator("∼".into())),
3174 "simeq" => Ok(MathExpr::Operator("≃".into())),
3175 "cong" => Ok(MathExpr::Operator("≅".into())),
3176 "equiv" => Ok(MathExpr::Operator("≡".into())),
3177 "propto" => Ok(MathExpr::Operator("∝".into())),
3178 "le" | "leq" => Ok(MathExpr::Operator("≤".into())),
3179 "ge" | "geq" => Ok(MathExpr::Operator("≥".into())),
3180 "ne" | "neq" => Ok(MathExpr::Operator("≠".into())),
3181 "ll" => Ok(MathExpr::Operator("≪".into())),
3182 "gg" => Ok(MathExpr::Operator("≫".into())),
3183 "prec" => Ok(MathExpr::Operator("≺".into())),
3184 "succ" => Ok(MathExpr::Operator("≻".into())),
3185 "preceq" => Ok(MathExpr::Operator("⪯".into())),
3186 "succeq" => Ok(MathExpr::Operator("⪰".into())),
3187 "parallel" => Ok(MathExpr::Operator("∥".into())),
3188 "perp" => Ok(MathExpr::Operator("⊥".into())),
3189 "asymp" => Ok(MathExpr::Operator("≍".into())),
3190 "doteq" => Ok(MathExpr::Operator("≐".into())),
3191 "models" => Ok(MathExpr::Operator("⊨".into())),
3192 "vdash" => Ok(MathExpr::Operator("⊢".into())),
3193 "dashv" => Ok(MathExpr::Operator("⊣".into())),
3194 "therefore" => Ok(MathExpr::Operator("∴".into())),
3195 "because" => Ok(MathExpr::Operator("∵".into())),
3196 "in" => Ok(MathExpr::Operator("∈".into())),
3198 "notin" => Ok(MathExpr::Operator("∉".into())),
3199 "ni" => Ok(MathExpr::Operator("∋".into())),
3200 "subset" => Ok(MathExpr::Operator("⊂".into())),
3201 "supset" => Ok(MathExpr::Operator("⊃".into())),
3202 "subseteq" => Ok(MathExpr::Operator("⊆".into())),
3203 "supseteq" => Ok(MathExpr::Operator("⊇".into())),
3204 "subsetneq" => Ok(MathExpr::Operator("⊊".into())),
3205 "supsetneq" => Ok(MathExpr::Operator("⊋".into())),
3206 "complement" => Ok(MathExpr::Operator("∁".into())),
3207 "forall" => Ok(MathExpr::Operator("∀".into())),
3209 "exists" => Ok(MathExpr::Operator("∃".into())),
3210 "nexists" => Ok(MathExpr::Operator("∄".into())),
3211 "neg" | "lnot" => Ok(MathExpr::Operator("¬".into())),
3212 "top" => Ok(MathExpr::Operator("⊤".into())),
3213 "bot" => Ok(MathExpr::Operator("⊥".into())),
3214 "implies" => Ok(extra_wide_operator("⟹")),
3215 "impliedby" => Ok(extra_wide_operator("⟸")),
3216 "iff" => Ok(extra_wide_operator("⟺")),
3217 "to" | "rightarrow" => Ok(MathExpr::Operator("→".into())),
3219 "leftarrow" | "gets" => Ok(MathExpr::Operator("←".into())),
3220 "leftrightarrow" => Ok(MathExpr::Operator("↔".into())),
3221 "Rightarrow" => Ok(MathExpr::Operator("⇒".into())),
3222 "Leftarrow" => Ok(MathExpr::Operator("⇐".into())),
3223 "Leftrightarrow" => Ok(MathExpr::Operator("⇔".into())),
3224 "longrightarrow" => Ok(MathExpr::Operator("⟶".into())),
3225 "longleftarrow" => Ok(MathExpr::Operator("⟵".into())),
3226 "longleftrightarrow" => Ok(MathExpr::Operator("⟷".into())),
3227 "Longrightarrow" => Ok(MathExpr::Operator("⟹".into())),
3228 "Longleftarrow" => Ok(MathExpr::Operator("⟸".into())),
3229 "Longleftrightarrow" => Ok(MathExpr::Operator("⟺".into())),
3230 "uparrow" => Ok(MathExpr::Operator("↑".into())),
3231 "downarrow" => Ok(MathExpr::Operator("↓".into())),
3232 "updownarrow" => Ok(MathExpr::Operator("↕".into())),
3233 "Uparrow" => Ok(MathExpr::Operator("⇑".into())),
3234 "Downarrow" => Ok(MathExpr::Operator("⇓".into())),
3235 "Updownarrow" => Ok(MathExpr::Operator("⇕".into())),
3236 "mapsto" => Ok(MathExpr::Operator("↦".into())),
3237 "longmapsto" => Ok(MathExpr::Operator("⟼".into())),
3238 "hookrightarrow" => Ok(MathExpr::Operator("↪".into())),
3239 "hookleftarrow" => Ok(MathExpr::Operator("↩".into())),
3240 "sum" => Ok(MathExpr::Operator("∑".into())),
3242 "prod" => Ok(MathExpr::Operator("∏".into())),
3243 "coprod" => Ok(MathExpr::Operator("∐".into())),
3244 "int" => Ok(MathExpr::Operator("∫".into())),
3245 "oint" => Ok(MathExpr::Operator("∮".into())),
3246 "iint" => Ok(MathExpr::Operator("∬".into())),
3247 "iiint" => Ok(MathExpr::Operator("∭".into())),
3248 "bigcup" => Ok(MathExpr::Operator("⋃".into())),
3249 "bigcap" => Ok(MathExpr::Operator("⋂".into())),
3250 "biguplus" => Ok(MathExpr::Operator("⨄".into())),
3251 "bigsqcup" => Ok(MathExpr::Operator("⨆".into())),
3252 "bigvee" => Ok(MathExpr::Operator("⋁".into())),
3253 "bigwedge" => Ok(MathExpr::Operator("⋀".into())),
3254 "bigoplus" => Ok(MathExpr::Operator("⨁".into())),
3255 "bigotimes" => Ok(MathExpr::Operator("⨂".into())),
3256 "bigodot" => Ok(MathExpr::Operator("⨀".into())),
3257 "nabla" => Ok(MathExpr::Operator("∇".into())),
3259 "dagger" => Ok(MathExpr::Operator("†".into())),
3260 "ddagger" => Ok(MathExpr::Operator("‡".into())),
3261 "mid" => Ok(MathExpr::Operator("|".into())),
3262 "angle" => Ok(MathExpr::Operator("∠".into())),
3263 "measuredangle" => Ok(MathExpr::Operator("∡".into())),
3264 "ldots" | "dots" => Ok(MathExpr::Text("...".into())),
3266 "cdots" => Ok(MathExpr::Operator("⋯".into())),
3267 "vdots" => Ok(MathExpr::Operator("⋮".into())),
3268 "ddots" => Ok(MathExpr::Operator("⋱".into())),
3269 "sin" | "cos" | "tan" | "cot" | "sec" | "csc" | "sinh" | "cosh" | "tanh" | "coth"
3271 | "arcsin" | "arccos" | "arctan" | "log" | "lg" | "ln" | "exp" | "lim" | "max"
3272 | "min" | "sup" | "inf" | "det" | "arg" | "deg" | "dim" | "hom" | "ker" => {
3273 Ok(MathExpr::Text(name))
3274 }
3275 "gcd" => Ok(MathExpr::Text("gcd".into())),
3276 "Pr" => Ok(MathExpr::Text("Pr".into())),
3277 "liminf" => Ok(MathExpr::Text("lim inf".into())),
3278 "limsup" => Ok(MathExpr::Text("lim sup".into())),
3279 "quad" => Ok(MathExpr::Space(1.0)),
3281 "qquad" => Ok(MathExpr::Space(2.0)),
3282 "thinspace" => Ok(MathExpr::Space(THIN_MATH_SPACE_EM)),
3283 "medspace" => Ok(MathExpr::Space(MEDIUM_MATH_SPACE_EM)),
3284 "thickspace" => Ok(MathExpr::Space(THICK_MATH_SPACE_EM)),
3285 "negthinspace" => Ok(MathExpr::Space(-THIN_MATH_SPACE_EM)),
3286 "negmedspace" => Ok(MathExpr::Space(-MEDIUM_MATH_SPACE_EM)),
3287 "negthickspace" => Ok(MathExpr::Space(-THICK_MATH_SPACE_EM)),
3288 "enspace" => Ok(MathExpr::Space(0.5)),
3289 "space" => Ok(MathExpr::Space(MEDIUM_MATH_SPACE_EM)),
3290 _ => match delimiter_command(&name) {
3291 Some(symbol) => Ok(MathExpr::Operator(symbol)),
3292 None => Ok(MathExpr::Identifier(format!("\\{name}"))),
3293 },
3294 }
3295 }
3296
3297 fn parse_required_group(&mut self) -> Result<MathExpr, MathParseError> {
3298 self.skip_ws();
3299 self.expect('{')?;
3300 self.parse_row(Some('}'))
3301 }
3302
3303 fn parse_text_group(&mut self) -> Result<String, MathParseError> {
3304 self.skip_ws();
3305 self.expect('{')?;
3306 let mut depth = 1;
3307 let mut text = String::new();
3308 while let Some(ch) = self.bump() {
3309 match ch {
3310 '\\' => {
3311 let escaped = self
3312 .bump()
3313 .ok_or_else(|| self.error("unclosed text group"))?;
3314 text.push(escaped);
3315 }
3316 '{' => {
3317 depth += 1;
3318 text.push(ch);
3319 }
3320 '}' => {
3321 depth -= 1;
3322 if depth == 0 {
3323 return Ok(text.split_whitespace().collect::<Vec<_>>().join(" "));
3324 }
3325 text.push(ch);
3326 }
3327 _ => text.push(ch),
3328 }
3329 }
3330 Err(self.error("unclosed text group"))
3331 }
3332
3333 fn parse_optional_bracket_group(&mut self) -> Result<Option<MathExpr>, MathParseError> {
3334 self.skip_ws();
3335 if self.peek() != Some('[') {
3336 return Ok(None);
3337 }
3338 self.bump();
3339 self.parse_row(Some(']')).map(Some)
3340 }
3341
3342 fn parse_delimiter(&mut self) -> Result<Option<String>, MathParseError> {
3343 self.skip_ws();
3344 let delimiter = match self.bump() {
3345 Some('.') => return Ok(None),
3346 Some('\\') => {
3347 let name = self.take_while(|c| c.is_ascii_alphabetic());
3348 if name.is_empty() {
3349 self.bump()
3350 .ok_or_else(|| self.error("expected delimiter after escape"))?
3351 .to_string()
3352 } else {
3353 delimiter_command(&name).unwrap_or_else(|| format!("\\{name}"))
3354 }
3355 }
3356 Some(ch) => ch.to_string(),
3357 None => return Err(self.error("expected delimiter")),
3358 };
3359 Ok(Some(delimiter))
3360 }
3361
3362 fn parse_environment_name(&mut self) -> Result<String, MathParseError> {
3363 self.skip_ws();
3364 self.expect('{')?;
3365 let name = self.take_while(|c| c != '}');
3366 self.expect('}')?;
3367 if name.is_empty() {
3368 return Err(self.error("expected environment name"));
3369 }
3370 Ok(name)
3371 }
3372
3373 fn parse_array_column_alignments(
3374 &mut self,
3375 ) -> Result<Vec<MathColumnAlignment>, MathParseError> {
3376 self.skip_ws();
3377 self.expect('{')?;
3378 let mut alignments = Vec::new();
3379 loop {
3380 match self.bump() {
3381 Some('}') => break,
3382 Some('l') => alignments.push(MathColumnAlignment::Left),
3383 Some('c') => alignments.push(MathColumnAlignment::Center),
3384 Some('r') => alignments.push(MathColumnAlignment::Right),
3385 Some('|') | Some(' ') | Some('\t') | Some('\n') | Some('\r') => {}
3386 Some(ch) => {
3387 return Err(
3388 self.error(&format!("unsupported array alignment specifier {ch:?}"))
3389 );
3390 }
3391 None => return Err(self.error("unclosed array alignment spec")),
3392 }
3393 }
3394 Ok(alignments)
3395 }
3396
3397 fn consume_environment_end(&mut self, expected: &str) -> Result<(), MathParseError> {
3398 self.consume_command("end")?;
3399 let found = self.parse_environment_name()?;
3400 if found == expected {
3401 Ok(())
3402 } else {
3403 Err(self.error(&format!("expected \\end{{{expected}}}")))
3404 }
3405 }
3406
3407 fn starts_with_row_separator(&self) -> bool {
3408 self.input[self.pos..].starts_with(r"\\")
3409 }
3410
3411 fn consume_row_separator(&mut self) -> Result<(), MathParseError> {
3412 if !self.starts_with_row_separator() {
3413 return Err(self.error(r"expected \\"));
3414 }
3415 self.expect('\\')?;
3416 self.expect('\\')
3417 }
3418
3419 fn skip_ws(&mut self) {
3420 while matches!(self.peek(), Some(ch) if ch.is_whitespace()) {
3421 self.bump();
3422 }
3423 }
3424
3425 fn expect(&mut self, expected: char) -> Result<(), MathParseError> {
3426 match self.bump() {
3427 Some(ch) if ch == expected => Ok(()),
3428 _ => Err(self.error(&format!("expected '{expected}'"))),
3429 }
3430 }
3431
3432 fn take_while(&mut self, mut f: impl FnMut(char) -> bool) -> String {
3433 let start = self.pos;
3434 while matches!(self.peek(), Some(ch) if f(ch)) {
3435 self.bump();
3436 }
3437 self.input[start..self.pos].to_string()
3438 }
3439
3440 fn starts_with_command(&self, command: &str) -> bool {
3441 let rest = &self.input[self.pos..];
3442 let Some(after_slash) = rest.strip_prefix('\\') else {
3443 return false;
3444 };
3445 let Some(after_command) = after_slash.strip_prefix(command) else {
3446 return false;
3447 };
3448 !matches!(after_command.chars().next(), Some(ch) if ch.is_ascii_alphabetic())
3449 }
3450
3451 fn consume_command(&mut self, command: &str) -> Result<(), MathParseError> {
3452 if !self.starts_with_command(command) {
3453 return Err(self.error(&format!("expected \\{command}")));
3454 }
3455 self.expect('\\')?;
3456 let found = self.take_while(|c| c.is_ascii_alphabetic());
3457 if found == command {
3458 Ok(())
3459 } else {
3460 Err(self.error(&format!("expected \\{command}")))
3461 }
3462 }
3463
3464 fn peek(&self) -> Option<char> {
3465 self.input[self.pos..].chars().next()
3466 }
3467
3468 fn bump(&mut self) -> Option<char> {
3469 let ch = self.peek()?;
3470 self.pos += ch.len_utf8();
3471 Some(ch)
3472 }
3473
3474 fn error(&self, message: &str) -> MathParseError {
3475 MathParseError {
3476 message: message.to_string(),
3477 byte: self.pos,
3478 }
3479 }
3480}
3481
3482fn extra_wide_operator(symbol: &str) -> MathExpr {
3483 let spacing = MEDIUM_MATH_SPACE_EM + THICK_MATH_SPACE_EM;
3484 MathExpr::OperatorWithMetadata {
3485 text: symbol.into(),
3486 lspace: Some(spacing),
3487 rspace: Some(spacing),
3488 large_operator: None,
3489 movable_limits: None,
3490 }
3491}
3492
3493fn delimiter_command(command: &str) -> Option<String> {
3494 let delimiter = match command {
3495 "lbrace" => "{",
3496 "rbrace" => "}",
3497 "lparen" => "(",
3498 "rparen" => ")",
3499 "lbrack" => "[",
3500 "rbrack" => "]",
3501 "langle" => "⟨",
3502 "rangle" => "⟩",
3503 "vert" => "|",
3504 "Vert" => "‖",
3505 "lfloor" => "⌊",
3506 "rfloor" => "⌋",
3507 "lceil" => "⌈",
3508 "rceil" => "⌉",
3509 _ => return None,
3510 };
3511 Some(delimiter.to_string())
3512}
3513
3514fn map_mathbb_expr(expr: MathExpr) -> MathExpr {
3515 match expr {
3516 MathExpr::Identifier(text) => MathExpr::Identifier(map_mathbb_text(&text)),
3517 MathExpr::Text(text) => MathExpr::Text(map_mathbb_text(&text)),
3518 MathExpr::Row(children) => MathExpr::row(children.into_iter().map(map_mathbb_expr)),
3519 other => other,
3520 }
3521}
3522
3523fn map_mathbb_text(text: &str) -> String {
3524 text.chars().map(map_mathbb_char).collect()
3525}
3526
3527fn map_mathbb_char(ch: char) -> char {
3528 match ch {
3529 'A' => '𝔸',
3530 'B' => '𝔹',
3531 'C' => 'ℂ',
3532 'D' => '𝔻',
3533 'E' => '𝔼',
3534 'F' => '𝔽',
3535 'G' => '𝔾',
3536 'H' => 'ℍ',
3537 'I' => '𝕀',
3538 'J' => '𝕁',
3539 'K' => '𝕂',
3540 'L' => '𝕃',
3541 'M' => '𝕄',
3542 'N' => 'ℕ',
3543 'O' => '𝕆',
3544 'P' => 'ℙ',
3545 'Q' => 'ℚ',
3546 'R' => 'ℝ',
3547 'S' => '𝕊',
3548 'T' => '𝕋',
3549 'U' => '𝕌',
3550 'V' => '𝕍',
3551 'W' => '𝕎',
3552 'X' => '𝕏',
3553 'Y' => '𝕐',
3554 'Z' => 'ℤ',
3555 _ => ch,
3556 }
3557}
3558
3559struct TexTableOptions {
3560 column_alignments: Vec<MathColumnAlignment>,
3561 column_gap: Option<f32>,
3562 row_gap: Option<f32>,
3563}
3564
3565fn default_tex_table_options(env: &str) -> TexTableOptions {
3566 match env {
3567 "cases" => TexTableOptions {
3568 column_alignments: vec![MathColumnAlignment::Left, MathColumnAlignment::Left],
3569 column_gap: Some(CASES_COL_GAP_EM),
3570 row_gap: None,
3571 },
3572 "aligned" | "align" => TexTableOptions {
3573 column_alignments: vec![MathColumnAlignment::Right, MathColumnAlignment::Left],
3574 column_gap: Some(MEDIUM_MATH_SPACE_EM),
3575 row_gap: None,
3576 },
3577 _ => TexTableOptions {
3578 column_alignments: Vec::new(),
3579 column_gap: None,
3580 row_gap: None,
3581 },
3582 }
3583}
3584
3585pub(crate) fn math_glyph_layout(
3586 text: &str,
3587 size: f32,
3588 weight: FontWeight,
3589) -> text_metrics::TextLayout {
3590 text_metrics::layout_text_with_line_height_and_family(
3591 text,
3592 size,
3593 text_metrics::line_height(size),
3594 FontFamily::Inter,
3595 weight,
3596 false,
3597 false,
3598 0.0,
3599 TextWrap::NoWrap,
3600 None,
3601 )
3602}
3603
3604pub(crate) fn resolved_math_color(color: Option<Color>) -> Color {
3605 color.unwrap_or(crate::tokens::FOREGROUND)
3606}
3607
3608#[cfg(test)]
3609mod tests {
3610 use super::*;
3611
3612 fn has_radical_shape(layout: &MathLayout) -> bool {
3613 layout
3614 .atoms
3615 .iter()
3616 .any(|atom| matches!(atom, MathAtom::Radical { .. } | MathAtom::GlyphId { .. }))
3617 }
3618
3619 fn expect_source(expr: &MathExpr, expected: Range<usize>) -> &MathExpr {
3620 let MathExpr::Source { source, body } = expr else {
3621 panic!("expected source wrapper, got {expr:?}");
3622 };
3623 assert_eq!(*source, expected);
3624 body
3625 }
3626
3627 fn assert_no_unknown_tex_commands(expr: &MathExpr) {
3628 match expr {
3629 MathExpr::Identifier(text) => {
3630 assert!(
3631 !text.starts_with('\\'),
3632 "unexpected raw TeX command identifier {text:?} in {expr:?}"
3633 );
3634 }
3635 MathExpr::Row(children) => {
3636 for child in children {
3637 assert_no_unknown_tex_commands(child);
3638 }
3639 }
3640 MathExpr::Fraction {
3641 numerator,
3642 denominator,
3643 } => {
3644 assert_no_unknown_tex_commands(numerator);
3645 assert_no_unknown_tex_commands(denominator);
3646 }
3647 MathExpr::Sqrt(child) => assert_no_unknown_tex_commands(child),
3648 MathExpr::Root { base, index } => {
3649 assert_no_unknown_tex_commands(base);
3650 assert_no_unknown_tex_commands(index);
3651 }
3652 MathExpr::Scripts { base, sub, sup } => {
3653 assert_no_unknown_tex_commands(base);
3654 if let Some(sub) = sub {
3655 assert_no_unknown_tex_commands(sub);
3656 }
3657 if let Some(sup) = sup {
3658 assert_no_unknown_tex_commands(sup);
3659 }
3660 }
3661 MathExpr::UnderOver { base, under, over } => {
3662 assert_no_unknown_tex_commands(base);
3663 if let Some(under) = under {
3664 assert_no_unknown_tex_commands(under);
3665 }
3666 if let Some(over) = over {
3667 assert_no_unknown_tex_commands(over);
3668 }
3669 }
3670 MathExpr::Accent { base, accent, .. } => {
3671 assert_no_unknown_tex_commands(base);
3672 assert_no_unknown_tex_commands(accent);
3673 }
3674 MathExpr::Fenced { body, .. } => assert_no_unknown_tex_commands(body),
3675 MathExpr::Table { rows, .. } => {
3676 for row in rows {
3677 for cell in row {
3678 assert_no_unknown_tex_commands(cell);
3679 }
3680 }
3681 }
3682 MathExpr::Source { body, .. } => assert_no_unknown_tex_commands(body),
3683 MathExpr::Operator(_)
3684 | MathExpr::OperatorWithMetadata { .. }
3685 | MathExpr::Text(_)
3686 | MathExpr::Number(_)
3687 | MathExpr::Space(_)
3688 | MathExpr::Error(_) => {}
3689 }
3690 }
3691
3692 #[test]
3693 fn tex_source_ranges_are_opt_in_and_do_not_change_layout() {
3694 let input = r"\frac{x_1}{2}";
3695 let plain = parse_tex(input).expect("plain tex");
3696 let sourced = parse_tex_with_source_ranges(input).expect("source-backed tex");
3697
3698 assert!(!matches!(plain, MathExpr::Source { .. }));
3699 assert_eq!(
3700 layout_math(&plain, 16.0, MathDisplay::Block),
3701 layout_math(&sourced, 16.0, MathDisplay::Block)
3702 );
3703 assert!(matches!(
3704 expect_source(&sourced, 0..input.len()).without_source(),
3705 MathExpr::Fraction { .. }
3706 ));
3707 }
3708
3709 #[test]
3710 fn tex_source_ranges_track_script_components() {
3711 let expr = parse_tex_with_source_ranges("x_1^2").expect("source-backed tex");
3712 let root = expect_source(&expr, 0..5);
3713 let body = expect_source(root, 0..5);
3714 let MathExpr::Scripts { base, sub, sup } = body else {
3715 panic!("expected scripts, got {body:?}");
3716 };
3717
3718 assert_eq!(
3719 expect_source(base, 0..1).without_source(),
3720 &MathExpr::Identifier("x".into())
3721 );
3722 assert_eq!(
3723 expect_source(sub.as_deref().expect("subscript"), 2..3).without_source(),
3724 &MathExpr::Number("1".into())
3725 );
3726 assert_eq!(
3727 expect_source(sup.as_deref().expect("superscript"), 4..5).without_source(),
3728 &MathExpr::Number("2".into())
3729 );
3730 }
3731
3732 #[cfg(feature = "symbols")]
3733 #[test]
3734 fn loads_bundled_open_type_math_constants() {
3735 let constants = open_type_math_constants().expect("bundled math font has a MATH table");
3736 assert!(
3737 constants
3738 .script_scale(16.0)
3739 .is_some_and(|size| size > 6.0 && size < 16.0),
3740 "script scale should come from Noto Sans Math"
3741 );
3742 assert!(
3743 constants
3744 .fraction_rule_thickness(16.0)
3745 .is_some_and(|thickness| thickness > 0.75 && thickness < 2.0),
3746 "fraction rule thickness should come from Noto Sans Math"
3747 );
3748 assert!(
3749 constants
3750 .axis_height(16.0)
3751 .is_some_and(|axis| axis > 1.0 && axis < 8.0),
3752 "axis height should come from Noto Sans Math"
3753 );
3754 assert!(
3755 constants
3756 .superscript_shift_up(16.0)
3757 .is_some_and(|shift| shift > 1.0 && shift < 16.0),
3758 "superscript shift should come from Noto Sans Math"
3759 );
3760 assert!(
3761 constants
3762 .subscript_shift_down(16.0)
3763 .is_some_and(|shift| shift > 1.0 && shift < 16.0),
3764 "subscript shift should come from Noto Sans Math"
3765 );
3766 assert!(
3767 constants
3768 .space_after_script(16.0)
3769 .is_some_and(|space| space > 0.1 && space < 4.0),
3770 "script spacing should come from Noto Sans Math"
3771 );
3772 assert!(
3773 constants
3774 .upper_limit_gap_min(16.0)
3775 .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3776 "upper limit gap should come from Noto Sans Math"
3777 );
3778 assert!(
3779 constants
3780 .lower_limit_baseline_drop_min(16.0)
3781 .is_some_and(|drop| drop > 1.0 && drop < 20.0),
3782 "lower limit baseline drop should come from Noto Sans Math"
3783 );
3784 assert!(
3785 constants
3786 .fraction_numerator_gap(16.0, true)
3787 .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3788 "display numerator gap should come from Noto Sans Math"
3789 );
3790 assert!(
3791 constants
3792 .fraction_denominator_gap(16.0, true)
3793 .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3794 "display denominator gap should come from Noto Sans Math"
3795 );
3796 assert!(
3797 constants
3798 .fraction_numerator_shift(16.0, true)
3799 .is_some_and(|shift| shift > 1.0 && shift < 24.0),
3800 "display numerator shift should come from Noto Sans Math"
3801 );
3802 assert!(
3803 constants
3804 .fraction_denominator_shift(16.0, true)
3805 .is_some_and(|shift| shift > 1.0 && shift < 24.0),
3806 "display denominator shift should come from Noto Sans Math"
3807 );
3808 assert!(
3809 constants
3810 .radical_rule_thickness(16.0)
3811 .is_some_and(|thickness| thickness > 0.75 && thickness < 2.0),
3812 "radical rule thickness should come from Noto Sans Math"
3813 );
3814 assert!(
3815 constants
3816 .radical_vertical_gap(16.0, true)
3817 .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3818 "display radical gap should come from Noto Sans Math"
3819 );
3820 assert!(
3821 constants
3822 .radical_kern_before_degree(16.0)
3823 .is_some_and(|kern| kern > 0.0 && kern < 8.0),
3824 "radical degree before-kern should come from Noto Sans Math"
3825 );
3826 assert!(
3827 constants
3828 .radical_kern_after_degree(16.0)
3829 .is_some_and(|kern| kern < 0.0 && kern > -8.0),
3830 "radical degree after-kern should come from Noto Sans Math"
3831 );
3832 assert!(
3833 constants
3834 .radical_degree_bottom_raise_fraction()
3835 .is_some_and(|raise| raise > 0.0 && raise < 1.0),
3836 "radical degree raise should come from Noto Sans Math"
3837 );
3838 assert!(
3839 constants
3840 .min_connector_overlap(16.0)
3841 .is_some_and(|overlap| overlap > 0.0),
3842 "delimiter connector overlap should come from Noto Sans Math"
3843 );
3844 assert!(
3845 constants
3846 .delimited_sub_formula_min_height(16.0)
3847 .is_some_and(|height| height > 8.0 && height < 40.0),
3848 "delimiter stretch threshold should come from Noto Sans Math"
3849 );
3850 assert!(
3851 constants.delimiter_variant_count('(') > 0,
3852 "left paren should expose vertical delimiter variants"
3853 );
3854 assert!(
3855 constants.delimiter_variant_count(RADICAL_GLYPH) > 0,
3856 "radical should expose vertical math glyph variants"
3857 );
3858 assert!(
3859 constants.delimiter_variant_count('∑') > 0,
3860 "summation should expose vertical math glyph variants"
3861 );
3862 assert!(
3863 constants.delimiter_variant_count('∫') > 0,
3864 "integral should expose vertical math glyph variants"
3865 );
3866 assert!(
3867 constants
3868 .delimiter_first_variant_glyph_id('(')
3869 .is_some_and(|glyph_id| glyph_id > 0),
3870 "left paren variants should preserve glyph IDs"
3871 );
3872 assert!(
3873 constants.delimiter_assembly_part_count('{') > 0,
3874 "left brace should expose a vertical delimiter assembly"
3875 );
3876 assert!(
3877 constants.delimiter_extender_part_count('{') > 0,
3878 "left brace assembly should expose extender parts"
3879 );
3880 assert!(
3881 constants.delimiter_has_assembly_connectors('{'),
3882 "left brace assembly should preserve connector metadata"
3883 );
3884 assert!(
3885 constants
3886 .delimiter_max_advance('(', 16.0)
3887 .is_some_and(|advance| advance > 16.0),
3888 "delimiter variant advances should scale into px"
3889 );
3890 }
3891
3892 #[test]
3893 fn parses_fraction_with_scripts() {
3894 let expr = parse_tex(r"\frac{a^2+b^2}{\sqrt{x_1+x_2}}").expect("valid tex");
3895 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
3896 assert!(layout.width > 20.0, "width = {}", layout.width);
3897 assert!(layout.ascent > 10.0, "ascent = {}", layout.ascent);
3898 assert!(layout.descent > 10.0, "descent = {}", layout.descent);
3899 assert!(
3900 layout
3901 .atoms
3902 .iter()
3903 .any(|atom| matches!(atom, MathAtom::Rule { .. })),
3904 "fraction should emit rule atoms"
3905 );
3906 assert!(
3907 has_radical_shape(&layout),
3908 "sqrt should emit a radical shape atom"
3909 );
3910 }
3911
3912 #[test]
3913 fn display_fraction_honors_baseline_shifts() {
3914 let layout = layout_math(
3915 &parse_tex(r"\frac{1}{2}").unwrap(),
3916 16.0,
3917 MathDisplay::Block,
3918 );
3919 let metrics = LayoutCtx {
3920 size: 16.0,
3921 display: MathDisplay::Block,
3922 }
3923 .metrics();
3924 let numerator_y = layout
3925 .atoms
3926 .iter()
3927 .find_map(|atom| match atom {
3928 MathAtom::Glyph {
3929 text, y_baseline, ..
3930 } if text == "1" => Some(*y_baseline),
3931 _ => None,
3932 })
3933 .expect("numerator baseline");
3934 let denominator_y = layout
3935 .atoms
3936 .iter()
3937 .find_map(|atom| match atom {
3938 MathAtom::Glyph {
3939 text, y_baseline, ..
3940 } if text == "2" => Some(*y_baseline),
3941 _ => None,
3942 })
3943 .expect("denominator baseline");
3944
3945 assert!(
3946 -numerator_y >= metrics.fraction_numerator_shift() - 0.1,
3947 "numerator shift = {}, min = {}",
3948 -numerator_y,
3949 metrics.fraction_numerator_shift()
3950 );
3951 assert!(
3952 denominator_y >= metrics.fraction_denominator_shift() - 0.1,
3953 "denominator shift = {denominator_y}, min = {}",
3954 metrics.fraction_denominator_shift()
3955 );
3956 }
3957
3958 #[test]
3959 fn scripts_with_sub_and_sup_keep_minimum_gap() {
3960 let layout = layout_math(&parse_tex(r"x_1^2").unwrap(), 16.0, MathDisplay::Inline);
3961 let sub_top = layout
3962 .atoms
3963 .iter()
3964 .find_map(|atom| match atom {
3965 MathAtom::Glyph {
3966 text,
3967 y_baseline,
3968 size,
3969 ..
3970 } if text == "1" => Some(
3971 y_baseline
3972 - LayoutCtx {
3973 size: *size,
3974 display: MathDisplay::Inline,
3975 }
3976 .metrics()
3977 .glyph_ascent(),
3978 ),
3979 _ => None,
3980 })
3981 .expect("subscript top");
3982 let sup_bottom = layout
3983 .atoms
3984 .iter()
3985 .find_map(|atom| match atom {
3986 MathAtom::Glyph {
3987 text,
3988 y_baseline,
3989 size,
3990 ..
3991 } if text == "2" => Some(
3992 y_baseline
3993 + LayoutCtx {
3994 size: *size,
3995 display: MathDisplay::Inline,
3996 }
3997 .metrics()
3998 .glyph_descent(),
3999 ),
4000 _ => None,
4001 })
4002 .expect("superscript bottom");
4003 let min_gap = LayoutCtx {
4004 size: 16.0,
4005 display: MathDisplay::Inline,
4006 }
4007 .metrics()
4008 .sub_superscript_gap();
4009
4010 assert!(
4011 sub_top - sup_bottom >= min_gap - 0.1,
4012 "script gap = {}, min = {min_gap}",
4013 sub_top - sup_bottom
4014 );
4015 }
4016
4017 #[test]
4018 fn parses_indexed_tex_root() {
4019 let expr = parse_tex(r"\sqrt[3]{x+1}").expect("valid tex");
4020 match expr {
4021 MathExpr::Root { base, index } => {
4022 assert_eq!(*index, MathExpr::Number("3".into()));
4023 assert!(matches!(*base, MathExpr::Row(_)));
4024 }
4025 other => panic!("expected indexed root, got {other:?}"),
4026 }
4027 let layout = layout_math(
4028 &parse_tex(r"\sqrt[3]{x+1}").unwrap(),
4029 16.0,
4030 MathDisplay::Inline,
4031 );
4032 assert!(
4033 has_radical_shape(&layout),
4034 "indexed root should emit a radical shape atom"
4035 );
4036 }
4037
4038 #[test]
4039 fn indexed_root_uses_open_type_degree_metrics() {
4040 let ctx = LayoutCtx {
4041 size: 16.0,
4042 display: MathDisplay::Inline,
4043 };
4044 let metrics = ctx.metrics();
4045 let base = parse_tex(r"x+1").expect("valid root base");
4046 let index_expr = MathExpr::Number("3".into());
4047 let root = layout_sqrt(&base, ctx);
4048 let index = layout_expr(&index_expr, ctx.script());
4049 let layout = layout_root(&base, &index_expr, ctx);
4050 let constants = metrics.font_constants().expect("bundled math constants");
4051 let expected_root_x = (constants
4052 .radical_kern_before_degree(ctx.size)
4053 .unwrap_or(0.0)
4054 + index.width
4055 + constants.radical_kern_after_degree(ctx.size).unwrap_or(0.0))
4056 .max(index.width * 0.35);
4057 let expected_index_dy = -root.ascent
4058 * constants
4059 .radical_degree_bottom_raise_fraction()
4060 .expect("root degree raise")
4061 - index.descent;
4062 let index_atom = layout
4063 .atoms
4064 .iter()
4065 .find_map(|atom| match atom {
4066 MathAtom::Glyph {
4067 text,
4068 x,
4069 y_baseline,
4070 ..
4071 } if text == "3" => Some((*x, *y_baseline)),
4072 _ => None,
4073 })
4074 .expect("root index glyph");
4075 let root_x = layout
4076 .atoms
4077 .iter()
4078 .find_map(|atom| match atom {
4079 MathAtom::GlyphId { rect, .. } => Some(rect.x),
4080 MathAtom::Radical { points, .. } => Some(points[0][0]),
4081 _ => None,
4082 })
4083 .expect("root radical atom");
4084
4085 assert!(
4086 (index_atom.0 - 0.0).abs() < 0.1,
4087 "index x = {}",
4088 index_atom.0
4089 );
4090 assert!(
4091 (index_atom.1 - expected_index_dy).abs() < 0.1,
4092 "index baseline = {}, expected {expected_index_dy}",
4093 index_atom.1
4094 );
4095 assert!(
4096 (root_x - expected_root_x).abs() < 0.1,
4097 "root x = {root_x}, expected {expected_root_x}"
4098 );
4099 }
4100
4101 #[test]
4102 fn parses_tex_accents() {
4103 let expr = parse_tex(r"\hat{x} + \overline{ab} + \vec{v}").expect("valid tex accents");
4104 let MathExpr::Row(children) = expr else {
4105 panic!("expected row expression");
4106 };
4107 assert!(
4108 children
4109 .iter()
4110 .filter(|child| matches!(child, MathExpr::Accent { .. }))
4111 .count()
4112 >= 3,
4113 "expected accent expressions in {children:?}"
4114 );
4115
4116 let overline = layout_math(
4117 &parse_tex(r"\overline{ab}").unwrap(),
4118 16.0,
4119 MathDisplay::Inline,
4120 );
4121 assert!(
4122 overline
4123 .atoms
4124 .iter()
4125 .any(|atom| matches!(atom, MathAtom::Rule { rect } if rect.y < -10.0)),
4126 "overline should emit a rule above the base"
4127 );
4128 }
4129
4130 #[test]
4131 fn parses_tex_text_groups() {
4132 let expr = parse_tex(r"x \text{ if } y \operatorname{max}").expect("valid tex text");
4133 let MathExpr::Row(children) = expr else {
4134 panic!("expected row expression");
4135 };
4136 assert!(
4137 children
4138 .iter()
4139 .any(|child| matches!(child, MathExpr::Text(text) if text == "if")),
4140 "expected text group in {children:?}"
4141 );
4142 assert!(
4143 children
4144 .iter()
4145 .any(|child| matches!(child, MathExpr::Text(text) if text == "max")),
4146 "expected operatorname text in {children:?}"
4147 );
4148 }
4149
4150 #[test]
4151 fn parses_common_tex_symbol_commands() {
4152 let expr =
4153 parse_tex(r"\alpha+\beta\to\gamma+\emptyset+\varnothing").expect("valid tex symbols");
4154 let MathExpr::Row(children) = expr else {
4155 panic!("expected row expression");
4156 };
4157 assert!(
4158 children
4159 .iter()
4160 .any(|child| matches!(child, MathExpr::Identifier(text) if text == "∅")),
4161 "expected empty-set symbol in {children:?}"
4162 );
4163 assert!(
4164 children.iter().all(
4165 |child| !matches!(child, MathExpr::Identifier(text) if text.starts_with('\\'))
4166 ),
4167 "expected supported symbol commands in {children:?}"
4168 );
4169 }
4170
4171 #[test]
4172 fn parses_aligned_tex_environment() {
4173 let expr = parse_tex(
4174 r"\begin{aligned}
4175(a + b)^2 &= a^2 + 2ab + b^2 \\
4176(a - b)^2 &= a^2 - 2ab + b^2 \\
4177(a+b)(a-b) &= a^2 - b^2
4178\end{aligned}",
4179 )
4180 .expect("valid aligned environment");
4181
4182 let MathExpr::Table {
4183 rows,
4184 column_alignments,
4185 ..
4186 } = expr
4187 else {
4188 panic!("expected aligned environment to parse as table");
4189 };
4190 assert_eq!(rows.len(), 3);
4191 assert!(rows.iter().all(|row| row.len() == 2), "rows = {rows:?}");
4192 assert_eq!(
4193 column_alignments,
4194 vec![MathColumnAlignment::Right, MathColumnAlignment::Left]
4195 );
4196 }
4197
4198 #[test]
4199 fn parses_markdown_math_stress_tex_commands() {
4200 let formulas = [
4201 r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}",
4202 r"\int_{-\infty}^{\infty} e^{-x^2}\, dx = \sqrt{\pi}",
4203 r"\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x)\, e^{-2\pi i x \xi}\, dx",
4204 r"\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}",
4205 r"\nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}",
4206 r"\begin{aligned}
4207S &= \sum_{k=0}^{n} r^k = 1 + r + r^2 + \cdots + r^n \\
4208rS &= r + r^2 + \cdots + r^{n+1} \\
4209S - rS &= 1 - r^{n+1} \\
4210S &= \frac{1 - r^{n+1}}{1 - r}, \quad r \neq 1
4211\end{aligned}",
4212 r"R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}",
4213 r"\det(A) = \begin{vmatrix} a & b & c \\ d & e & f \\ g & h & i \end{vmatrix}",
4214 r"f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h}",
4215 r"P(A \mid B) = \frac{P(B \mid A)\, P(A)}{P(B)}",
4216 r"f(x \mid \mu, \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}}",
4217 r"\mathbb{E}[X] = \int_{-\infty}^{\infty} x\, f(x)\, dx, \qquad \operatorname{Var}(X) = \mathbb{E}[X^2] - (\mathbb{E}[X])^2",
4218 r"( x + y )^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k",
4219 r"\varphi(n) = n \prod_{p \mid n} \left(1 - \frac{1}{p}\right)",
4220 r"A = A^\dagger",
4221 r"E_n = \frac{n^2 \pi^2 \hbar^2}{2mL^2}",
4222 r"|\langle \mathbf{u}, \mathbf{v} \rangle|^2 \leq \langle \mathbf{u}, \mathbf{u} \rangle \cdot \langle \mathbf{v}, \mathbf{v} \rangle",
4223 r"\alpha,\ \beta,\ \gamma,\ \delta,\ \varepsilon,\ \zeta,\ \eta,\ \theta,\ \iota,\ \kappa,\ \lambda,\ \mu,\ \nu,\ \xi,\ \pi,\ \rho,\ \sigma,\ \tau,\ \upsilon,\ \phi,\ \chi,\ \psi,\ \omega",
4224 r"\Gamma(n) = (n-1)! \qquad \Gamma\!\left(\tfrac{1}{2}\right) = \sqrt{\pi}",
4225 r"\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s}, \quad \Re(s) > 1",
4226 ];
4227
4228 for formula in formulas {
4229 let expr = parse_tex(formula)
4230 .unwrap_or_else(|err| panic!("failed to parse {formula:?}: {}", err.message));
4231 assert_no_unknown_tex_commands(&expr);
4232 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4233 assert!(
4234 layout.width.is_finite() && layout.height().is_finite(),
4235 "layout should be finite for {formula:?}: {layout:?}"
4236 );
4237 }
4238 }
4239
4240 #[test]
4241 fn operator_metadata_covers_spacing_and_large_ops() {
4242 let plus = operator_info("+");
4243 assert_eq!(plus.class, MathOperatorClass::Binary);
4244 assert!(plus.lspace_em > 0.0);
4245 assert!(plus.rspace_em > 0.0);
4246
4247 let comma = operator_info(",");
4248 assert_eq!(comma.class, MathOperatorClass::Punctuation);
4249 assert_eq!(comma.lspace_em, 0.0);
4250 assert!(comma.rspace_em > 0.0);
4251
4252 let sum = operator_info("∑");
4253 assert_eq!(sum.class, MathOperatorClass::Large);
4254 assert!(sum.large_operator);
4255 assert!(sum.movable_limits);
4256
4257 let integral = operator_info("∫");
4258 assert_eq!(integral.class, MathOperatorClass::Large);
4259 assert!(integral.large_operator);
4260 assert!(!integral.movable_limits);
4261
4262 let wedge = operator_info("∧");
4264 assert_eq!(wedge.class, MathOperatorClass::Binary);
4265 let oplus = operator_info("⊕");
4266 assert_eq!(oplus.class, MathOperatorClass::Binary);
4267
4268 let element_of = operator_info("∈");
4269 assert_eq!(element_of.class, MathOperatorClass::Relation);
4270 let double_right_arrow = operator_info("⇒");
4271 assert_eq!(double_right_arrow.class, MathOperatorClass::Relation);
4272
4273 let coproduct = operator_info("∐");
4274 assert_eq!(coproduct.class, MathOperatorClass::Large);
4275 assert!(coproduct.movable_limits);
4276
4277 let contour_integral = operator_info("∮");
4278 assert_eq!(contour_integral.class, MathOperatorClass::Large);
4279 assert!(contour_integral.large_operator);
4280 assert!(!contour_integral.movable_limits);
4281 }
4282
4283 #[test]
4284 fn parses_logic_and_set_theory_commands() {
4285 let expr = parse_tex(r"\forall x \in S, \exists y \notin T \implies x \neq y")
4286 .expect("valid logic tex");
4287 assert_no_unknown_tex_commands(&expr);
4288
4289 let MathExpr::Row(children) = &expr else {
4290 panic!("expected row");
4291 };
4292
4293 assert!(
4294 children
4295 .iter()
4296 .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∀")),
4297 "expected ∀ in {children:?}"
4298 );
4299 assert!(
4300 children
4301 .iter()
4302 .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∃")),
4303 "expected ∃ in {children:?}"
4304 );
4305 assert!(
4306 children
4307 .iter()
4308 .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∈")),
4309 "expected ∈ in {children:?}"
4310 );
4311 assert!(
4312 children
4313 .iter()
4314 .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∉")),
4315 "expected ∉ in {children:?}"
4316 );
4317
4318 let implies = children
4319 .iter()
4320 .find_map(|child| match child.without_source() {
4321 MathExpr::OperatorWithMetadata {
4322 text,
4323 lspace,
4324 rspace,
4325 ..
4326 } if text == "⟹" => Some((*lspace, *rspace)),
4327 _ => None,
4328 })
4329 .expect("expected \\implies operator with metadata");
4330 let (lspace, rspace) = implies;
4331 assert!(
4333 lspace.is_some_and(|v| v > MEDIUM_MATH_SPACE_EM),
4334 "lspace = {lspace:?}"
4335 );
4336 assert!(
4337 rspace.is_some_and(|v| v > MEDIUM_MATH_SPACE_EM),
4338 "rspace = {rspace:?}"
4339 );
4340 }
4341
4342 #[test]
4343 fn parses_function_like_operators_with_limits() {
4344 let expr = parse_tex(r"\gcd_{p \mid n}(p) + \liminf_{n \to \infty} a_n")
4345 .expect("valid function tex");
4346 assert_no_unknown_tex_commands(&expr);
4347
4348 let layout = layout_math(&expr, 22.0, MathDisplay::Block);
4349 assert!(layout.width.is_finite() && layout.height().is_finite());
4350
4351 assert!(is_display_limits_base(&MathExpr::Text("gcd".into())));
4353 assert!(is_display_limits_base(&MathExpr::Text("Pr".into())));
4354 assert!(is_display_limits_base(&MathExpr::Text("det".into())));
4355 assert!(is_display_limits_base(&MathExpr::Text("lim inf".into())));
4356 assert!(is_display_limits_base(&MathExpr::Text("lim sup".into())));
4357 }
4358
4359 #[test]
4360 fn parses_bare_delimiter_commands_as_operators() {
4361 let expr = parse_tex(r"\lfloor x \rfloor + \lceil y \rceil + \langle u, v \rangle")
4362 .expect("valid bare delimiter tex");
4363 assert_no_unknown_tex_commands(&expr);
4364 let MathExpr::Row(children) = &expr else {
4365 panic!("expected row");
4366 };
4367 for symbol in ["⌊", "⌋", "⌈", "⌉", "⟨", "⟩"] {
4368 assert!(
4369 children
4370 .iter()
4371 .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == symbol)),
4372 "expected {symbol} in {children:?}"
4373 );
4374 }
4375 }
4376
4377 #[test]
4378 fn parses_arrows_and_big_operators() {
4379 let formulas = [
4380 r"f : A \hookrightarrow B",
4381 r"x \mapsto x^2",
4382 r"a \Rightarrow b \Leftrightarrow c",
4383 r"\bigoplus_{i=1}^{n} V_i \cong \bigotimes_{j=1}^{m} W_j",
4384 r"\oint_{\partial \Omega} \omega = \iint_{\Omega} d\omega",
4385 r"\coprod_{i \in I} X_i",
4386 r"\bigvee_{k} a_k \wedge \bigwedge_{k} b_k",
4387 r"A \subseteq B \subset C \supseteq D \supset E",
4388 r"x \equiv y",
4389 r"\therefore \forall \epsilon > 0, \exists \delta > 0",
4390 r"\neg p \vee q \iff p \implies q",
4391 r"\gcd(a, b) \cdot \mathrm{lcm}(a, b) = a \cdot b",
4392 r"\Pr(A \cup B) \leq \Pr(A) + \Pr(B)",
4393 r"\liminf_{n \to \infty} a_n \leq \limsup_{n \to \infty} a_n",
4394 r"\sin^2\theta + \cos^2\theta = 1, \quad \tan\theta = \frac{\sin\theta}{\cos\theta}",
4395 r"\arcsin x + \arccos x = \frac{\pi}{2}",
4396 r"\sinh x = \frac{e^x - e^{-x}}{2}, \quad \cosh x = \frac{e^x + e^{-x}}{2}",
4397 r"\Pi_{i} a_i \prec \Sigma_{i} a_i \succ \Phi_{i} a_i",
4398 r"a \parallel b, \quad u \perp v",
4399 r"\vec{v} \cdot \vec{w} = \|\vec{v}\| \|\vec{w}\| \cos\theta",
4400 r"x \ll y \ll z, \quad a \gg b \gg c",
4401 r"\aleph_0 < 2^{\aleph_0}",
4402 r"\Im(z) + i\,\Re(z), \quad \ell^2(\mathbb{N})",
4403 r"x \star y \ne y \star x, \quad a \oplus b \otimes c",
4404 r"p \models \phi \vdash \psi \dashv \rho",
4405 ];
4406
4407 for formula in formulas {
4408 let expr = parse_tex(formula)
4409 .unwrap_or_else(|err| panic!("failed to parse {formula:?}: {}", err.message));
4410 assert_no_unknown_tex_commands(&expr);
4411 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4412 assert!(
4413 layout.width.is_finite() && layout.height().is_finite(),
4414 "layout finite for {formula:?}"
4415 );
4416 }
4417 }
4418
4419 #[test]
4420 fn display_sum_scripts_layout_as_limits() {
4421 let expr = parse_tex(r"\sum_{i=1}^{n} x_i").expect("valid tex");
4422 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4423 let metrics = LayoutCtx {
4424 size: 16.0,
4425 display: MathDisplay::Block,
4426 }
4427 .metrics();
4428 let sum_center_y = layout
4429 .atoms
4430 .iter()
4431 .find_map(|atom| match atom {
4432 MathAtom::Glyph {
4433 text, y_baseline, ..
4434 } if text == "∑" => Some(*y_baseline),
4435 MathAtom::GlyphId { rect, .. } => Some(rect.y + rect.h * 0.5),
4436 _ => None,
4437 })
4438 .expect("sum center");
4439 let upper_y = layout
4440 .atoms
4441 .iter()
4442 .find_map(|atom| match atom {
4443 MathAtom::Glyph {
4444 text, y_baseline, ..
4445 } if text == "n" => Some(*y_baseline),
4446 _ => None,
4447 })
4448 .expect("upper limit baseline");
4449 let lower_y = layout
4450 .atoms
4451 .iter()
4452 .find_map(|atom| match atom {
4453 MathAtom::Glyph {
4454 text, y_baseline, ..
4455 } if text == "i" => Some(*y_baseline),
4456 _ => None,
4457 })
4458 .expect("lower limit baseline");
4459 assert!(
4460 layout
4461 .atoms
4462 .iter()
4463 .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "n" && *y_baseline < 0.0)),
4464 "sum upper limit should sit above the operator"
4465 );
4466 assert!(
4467 layout
4468 .atoms
4469 .iter()
4470 .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "i" && *y_baseline > 0.0)),
4471 "sum lower limit should sit below the operator"
4472 );
4473 assert!(
4474 sum_center_y - upper_y >= metrics.upper_limit_baseline_rise() - 0.1,
4475 "upper limit rise = {}, min = {}",
4476 sum_center_y - upper_y,
4477 metrics.upper_limit_baseline_rise()
4478 );
4479 assert!(
4480 lower_y - sum_center_y >= metrics.lower_limit_baseline_drop() - 0.1,
4481 "lower limit drop = {}, min = {}",
4482 lower_y - sum_center_y,
4483 metrics.lower_limit_baseline_drop()
4484 );
4485 assert!(
4486 layout
4487 .atoms
4488 .iter()
4489 .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4490 "display sum should use an OpenType operator variant"
4491 );
4492 assert!(
4493 (sum_center_y + metrics.math_axis_shift()).abs() < 0.75,
4494 "display sum should center on the parent math axis"
4495 );
4496 }
4497
4498 #[test]
4499 fn display_integral_uses_open_type_variant() {
4500 let display = layout_math(&parse_tex(r"\int").unwrap(), 16.0, MathDisplay::Block);
4501 let inline = layout_math(&parse_tex(r"\int").unwrap(), 16.0, MathDisplay::Inline);
4502 assert!(
4503 display
4504 .atoms
4505 .iter()
4506 .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4507 "display integral should use an OpenType operator variant"
4508 );
4509 assert!(
4510 display.height() > inline.height() * 1.4,
4511 "display integral height = {}, inline height = {}",
4512 display.height(),
4513 inline.height()
4514 );
4515 }
4516
4517 #[test]
4518 fn mathml_largeop_false_keeps_integral_unexpanded() {
4519 let expr = parse_mathml(r#"<math><mo largeop="false">∫</mo></math>"#)
4520 .expect("valid MathML integral");
4521 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4522 assert!(
4523 !layout
4524 .atoms
4525 .iter()
4526 .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4527 "largeop=false should keep display integral on the ordinary glyph path"
4528 );
4529 }
4530
4531 #[test]
4532 fn display_integral_scripts_stay_on_side_of_large_operator() {
4533 let layout = layout_math(
4534 &parse_tex(r"\int_0^1 f(x)dx").unwrap(),
4535 16.0,
4536 MathDisplay::Block,
4537 );
4538 let integral_rect = layout
4539 .atoms
4540 .iter()
4541 .find_map(|atom| match atom {
4542 MathAtom::GlyphId { rect, .. } => Some(*rect),
4543 _ => None,
4544 })
4545 .expect("large integral glyph");
4546 let lower = layout
4547 .atoms
4548 .iter()
4549 .find_map(|atom| match atom {
4550 MathAtom::Glyph { text, x, .. } if text == "0" => Some(*x),
4551 _ => None,
4552 })
4553 .expect("lower integral script");
4554 let upper = layout
4555 .atoms
4556 .iter()
4557 .find_map(|atom| match atom {
4558 MathAtom::Glyph { text, x, .. } if text == "1" => Some(*x),
4559 _ => None,
4560 })
4561 .expect("upper integral script");
4562
4563 assert!(
4564 lower >= integral_rect.right() - 0.5 && upper >= integral_rect.right() - 0.5,
4565 "integral scripts should stay to the side, rect = {integral_rect:?}, lower x = {lower}, upper x = {upper}"
4566 );
4567 }
4568
4569 #[test]
4570 fn parses_tex_left_right_fences() {
4571 let expr = parse_tex(r"\left(\frac{a}{b}\right)").expect("valid fenced tex");
4572 match expr {
4573 MathExpr::Fenced { open, close, body } => {
4574 assert_eq!(open.as_deref(), Some("("));
4575 assert_eq!(close.as_deref(), Some(")"));
4576 assert!(matches!(*body, MathExpr::Fraction { .. }));
4577 }
4578 other => panic!("expected fenced expression, got {other:?}"),
4579 }
4580 let layout = layout_math(
4581 &parse_tex(r"\left(\begin{matrix}a\\b\\c\end{matrix}\right)").unwrap(),
4582 16.0,
4583 MathDisplay::Inline,
4584 );
4585 assert!(
4586 layout
4587 .atoms
4588 .iter()
4589 .any(|atom| matches!(atom, MathAtom::GlyphId { rect, .. } if rect.h > 16.0)),
4590 "fence should emit a stretched OpenType delimiter variant glyph"
4591 );
4592 }
4593
4594 #[test]
4595 fn simple_tex_left_right_fences_remain_glyphs() {
4596 let layout = layout_math(
4597 &parse_tex(r"\left(x\right)").unwrap(),
4598 16.0,
4599 MathDisplay::Inline,
4600 );
4601 assert!(
4602 !layout
4603 .atoms
4604 .iter()
4605 .any(|atom| matches!(atom, MathAtom::Delimiter { .. })),
4606 "simple fences should stay as glyphs below the font stretch threshold"
4607 );
4608 assert!(
4609 layout
4610 .atoms
4611 .iter()
4612 .any(|atom| matches!(atom, MathAtom::Glyph { text, .. } if text == "(")),
4613 "left fence should emit a glyph atom"
4614 );
4615 assert!(
4616 layout
4617 .atoms
4618 .iter()
4619 .any(|atom| matches!(atom, MathAtom::Glyph { text, .. } if text == ")")),
4620 "right fence should emit a glyph atom"
4621 );
4622 }
4623
4624 #[test]
4625 fn stretched_tex_fences_use_open_type_variant_glyphs() {
4626 let layout = layout_math(
4627 &parse_tex(r"\left(\begin{matrix}a&b\\c&d\end{matrix}\right)").unwrap(),
4628 16.0,
4629 MathDisplay::Inline,
4630 );
4631 assert!(
4632 layout
4633 .atoms
4634 .iter()
4635 .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4636 "moderately stretched fences should use exact OpenType delimiter variant glyphs"
4637 );
4638 }
4639
4640 #[test]
4641 fn very_tall_tex_fences_use_open_type_assembly_parts() {
4642 let expr =
4643 parse_tex(r"\left\{\begin{matrix}a\\b\\c\\d\\e\\f\\g\\h\end{matrix}\right.").unwrap();
4644 let layout = layout_math(&expr, 16.0, MathDisplay::Inline);
4645 let glyph_id_count = layout
4646 .atoms
4647 .iter()
4648 .filter(|atom| matches!(atom, MathAtom::GlyphId { .. }))
4649 .count();
4650 assert!(
4651 glyph_id_count > 2,
4652 "very tall fences should use repeated OpenType assembly glyph parts"
4653 );
4654 assert!(
4655 !layout
4656 .atoms
4657 .iter()
4658 .any(|atom| matches!(atom, MathAtom::Delimiter { .. })),
4659 "font assembly should avoid the hand-drawn delimiter fallback"
4660 );
4661 let MathExpr::Fenced { body, .. } = expr else {
4662 panic!("expected fenced expression");
4663 };
4664 let ctx = LayoutCtx {
4665 size: 16.0,
4666 display: MathDisplay::Inline,
4667 };
4668 let target_rect = delimiter_rect(&layout_expr(&body, ctx), ctx);
4669 let assembled_top = layout
4670 .atoms
4671 .iter()
4672 .filter_map(|atom| match atom {
4673 MathAtom::GlyphId { rect, .. } => Some(rect.y),
4674 _ => None,
4675 })
4676 .fold(f32::INFINITY, f32::min);
4677 let assembled_bottom = layout
4678 .atoms
4679 .iter()
4680 .filter_map(|atom| match atom {
4681 MathAtom::GlyphId { rect, .. } => Some(rect.y + rect.h),
4682 _ => None,
4683 })
4684 .fold(f32::NEG_INFINITY, f32::max);
4685 assert!(
4686 assembled_bottom - assembled_top <= target_rect.h + 0.5,
4687 "assembled delimiter height should track target height"
4688 );
4689 }
4690
4691 #[test]
4692 fn rejects_unmatched_tex_right_fence() {
4693 let err = parse_tex(r"x \right)").expect_err("invalid unmatched fence");
4694 assert!(err.message.contains("unexpected \\right"));
4695 }
4696
4697 #[test]
4698 fn parses_tex_matrix_environment() {
4699 let expr = parse_tex(r"\begin{matrix}a&b\\c&d\end{matrix}").expect("valid matrix");
4700 match expr {
4701 MathExpr::Table {
4702 rows,
4703 column_alignments,
4704 ..
4705 } => {
4706 assert_eq!(rows.len(), 2);
4707 assert_eq!(rows[0].len(), 2);
4708 assert_eq!(rows[1].len(), 2);
4709 assert_eq!(rows[0][0], MathExpr::Identifier("a".into()));
4710 assert_eq!(rows[1][1], MathExpr::Identifier("d".into()));
4711 assert!(column_alignments.is_empty());
4712 }
4713 other => panic!("expected table expression, got {other:?}"),
4714 }
4715 }
4716
4717 #[test]
4718 fn parses_tex_bmatrix_as_fenced_table() {
4719 let expr =
4720 parse_tex(r"\begin{bmatrix}a&b\\c&d\end{bmatrix}").expect("valid bracketed matrix");
4721 match expr {
4722 MathExpr::Fenced { open, close, body } => {
4723 assert_eq!(open.as_deref(), Some("["));
4724 assert_eq!(close.as_deref(), Some("]"));
4725 match body.as_ref() {
4726 MathExpr::Table { rows, .. } => {
4727 assert_eq!(rows.len(), 2);
4728 assert_eq!(rows[0].len(), 2);
4729 }
4730 other => panic!("expected table body, got {other:?}"),
4731 }
4732 }
4733 other => panic!("expected fenced matrix, got {other:?}"),
4734 }
4735 }
4736
4737 #[test]
4738 fn parses_tex_cases_as_left_braced_table() {
4739 let expr = parse_tex(r"\begin{cases}x&x>0\\-x&x<0\end{cases}").expect("valid cases");
4740 match expr {
4741 MathExpr::Fenced { open, close, body } => {
4742 assert_eq!(open.as_deref(), Some("{"));
4743 assert_eq!(close.as_deref(), None);
4744 match body.as_ref() {
4745 MathExpr::Table {
4746 column_alignments,
4747 column_gap,
4748 ..
4749 } => {
4750 assert_eq!(
4751 column_alignments,
4752 &vec![MathColumnAlignment::Left, MathColumnAlignment::Left]
4753 );
4754 assert_eq!(*column_gap, Some(CASES_COL_GAP_EM));
4755 }
4756 other => panic!("expected table body, got {other:?}"),
4757 }
4758 }
4759 other => panic!("expected left-braced cases table, got {other:?}"),
4760 }
4761 }
4762
4763 #[test]
4764 fn parses_tex_array_column_alignments() {
4765 let expr = parse_tex(r"\begin{array}{lr}x&100\\xx&2\end{array}").expect("valid array");
4766 match expr {
4767 MathExpr::Table {
4768 rows,
4769 column_alignments,
4770 ..
4771 } => {
4772 assert_eq!(rows.len(), 2);
4773 assert_eq!(
4774 column_alignments,
4775 vec![MathColumnAlignment::Left, MathColumnAlignment::Right]
4776 );
4777 }
4778 other => panic!("expected array table, got {other:?}"),
4779 }
4780 }
4781
4782 #[test]
4783 fn ignores_trailing_tex_table_row_separator() {
4784 let expr = parse_tex(r"\begin{matrix}a&b\\c&d\\\end{matrix}")
4785 .expect("valid matrix with trailing row separator");
4786 match expr {
4787 MathExpr::Table { rows, .. } => {
4788 assert_eq!(rows.len(), 2);
4789 assert_eq!(rows[0].len(), 2);
4790 assert_eq!(rows[1].len(), 2);
4791 }
4792 other => panic!("expected table expression, got {other:?}"),
4793 }
4794 }
4795
4796 #[test]
4797 fn rejects_inconsistent_tex_table_columns() {
4798 let err =
4799 parse_tex(r"\begin{matrix}a&b\\c\end{matrix}").expect_err("invalid ragged matrix");
4800 assert!(err.message.contains("inconsistent column count"));
4801 }
4802
4803 #[test]
4804 fn rejects_mismatched_tex_array_alignment_spec() {
4805 let err = parse_tex(r"\begin{array}{lr}x&100&z\\xx&2&y\end{array}")
4806 .expect_err("invalid array alignment spec");
4807 assert!(err.message.contains("alignment spec has 2 columns"));
4808 }
4809
4810 #[test]
4811 fn table_layout_honors_column_alignment() {
4812 let left_aligned = layout_math(
4813 &MathExpr::Table {
4814 rows: vec![
4815 vec![MathExpr::Identifier("x".into())],
4816 vec![MathExpr::Identifier("xxxx".into())],
4817 ],
4818 column_alignments: vec![MathColumnAlignment::Left],
4819 column_gap: None,
4820 row_gap: None,
4821 },
4822 16.0,
4823 MathDisplay::Inline,
4824 );
4825 let right_aligned = layout_math(
4826 &MathExpr::Table {
4827 rows: vec![
4828 vec![MathExpr::Identifier("x".into())],
4829 vec![MathExpr::Identifier("xxxx".into())],
4830 ],
4831 column_alignments: vec![MathColumnAlignment::Right],
4832 column_gap: None,
4833 row_gap: None,
4834 },
4835 16.0,
4836 MathDisplay::Inline,
4837 );
4838 let left_x = left_aligned
4839 .atoms
4840 .iter()
4841 .find_map(|atom| match atom {
4842 MathAtom::Glyph { text, x, .. } if text == "x" => Some(*x),
4843 _ => None,
4844 })
4845 .expect("left-aligned first cell glyph");
4846 let right_x = right_aligned
4847 .atoms
4848 .iter()
4849 .find_map(|atom| match atom {
4850 MathAtom::Glyph { text, x, .. } if text == "x" => Some(*x),
4851 _ => None,
4852 })
4853 .expect("right-aligned first cell glyph");
4854
4855 assert!(left_x < 0.1, "left-aligned glyph x = {left_x}");
4856 assert!(
4857 right_x > left_x + 10.0,
4858 "right alignment should shift narrow cells across wider columns"
4859 );
4860 }
4861
4862 #[test]
4863 fn table_layout_honors_table_spacing() {
4864 let loose = layout_math(
4865 &MathExpr::Table {
4866 rows: vec![
4867 vec![
4868 MathExpr::Identifier("a".into()),
4869 MathExpr::Identifier("b".into()),
4870 ],
4871 vec![
4872 MathExpr::Identifier("c".into()),
4873 MathExpr::Identifier("d".into()),
4874 ],
4875 ],
4876 column_alignments: Vec::new(),
4877 column_gap: Some(2.0),
4878 row_gap: Some(1.0),
4879 },
4880 16.0,
4881 MathDisplay::Inline,
4882 );
4883 let tight = layout_math(
4884 &MathExpr::Table {
4885 rows: vec![
4886 vec![
4887 MathExpr::Identifier("a".into()),
4888 MathExpr::Identifier("b".into()),
4889 ],
4890 vec![
4891 MathExpr::Identifier("c".into()),
4892 MathExpr::Identifier("d".into()),
4893 ],
4894 ],
4895 column_alignments: Vec::new(),
4896 column_gap: Some(0.25),
4897 row_gap: Some(0.1),
4898 },
4899 16.0,
4900 MathDisplay::Inline,
4901 );
4902
4903 assert!(
4904 loose.width > tight.width + 20.0,
4905 "loose width = {}, tight width = {}",
4906 loose.width,
4907 tight.width
4908 );
4909 assert!(
4910 loose.height() > tight.height() + 10.0,
4911 "loose height = {}, tight height = {}",
4912 loose.height(),
4913 tight.height()
4914 );
4915 }
4916
4917 #[test]
4918 fn table_layout_centers_on_math_axis() {
4919 let layout = layout_math(
4920 &MathExpr::Table {
4921 rows: vec![
4922 vec![
4923 MathExpr::Identifier("a".into()),
4924 MathExpr::Identifier("b".into()),
4925 ],
4926 vec![
4927 MathExpr::Identifier("c".into()),
4928 MathExpr::Identifier("d".into()),
4929 ],
4930 ],
4931 column_alignments: Vec::new(),
4932 column_gap: None,
4933 row_gap: None,
4934 },
4935 16.0,
4936 MathDisplay::Block,
4937 );
4938 let visual_center_y = (layout.descent - layout.ascent) * 0.5;
4939 assert!(
4940 visual_center_y < -2.0,
4941 "table visual center should sit on the math axis above baseline, got {visual_center_y}"
4942 );
4943 }
4944
4945 #[test]
4946 fn math_axis_prefers_open_type_axis_height() {
4947 let size = 14.0;
4948 let metrics = LayoutCtx {
4949 size,
4950 display: MathDisplay::Block,
4951 }
4952 .metrics();
4953 let expected = metrics
4954 .font_constants()
4955 .and_then(|constants| constants.axis_height(size))
4956 .unwrap_or_else(|| {
4957 metrics
4958 .operator_axis_shift()
4959 .expect("operator axis fallback")
4960 });
4961
4962 assert!(
4963 (metrics.math_axis_shift() - expected).abs() < 0.1,
4964 "axis = {}, expected = {expected}",
4965 metrics.math_axis_shift()
4966 );
4967 }
4968
4969 #[test]
4970 fn rejects_mismatched_tex_environment_end() {
4971 let err = parse_tex(r"\begin{matrix}a\end{pmatrix}").expect_err("invalid environment");
4972 assert!(err.message.contains(r"expected \end{matrix}"));
4973 }
4974
4975 #[test]
4976 fn reports_unclosed_group() {
4977 let err = parse_tex(r"\frac{1}{x").expect_err("invalid tex");
4978 assert!(err.message.contains("unclosed group"));
4979 }
4980
4981 #[test]
4982 fn rejects_tex_double_scripts_like_tex_does() {
4983 let err = parse_tex("x_1_2").expect_err("double subscript");
4986 assert!(err.message.contains("double subscript"), "{}", err.message);
4987 let err = parse_tex("x^1^2").expect_err("double superscript");
4988 assert!(
4989 err.message.contains("double superscript"),
4990 "{}",
4991 err.message
4992 );
4993 parse_tex("x_1^2").expect("sub then sup");
4995 parse_tex("x^2_1").expect("sup then sub");
4996 parse_tex("x_{1_2}").expect("braced nested subscript");
4997 parse_tex("{x_1}_2").expect("braced base with outer subscript");
4998 }
4999
5000 #[test]
5001 fn negative_mathml_operator_spacing_tightens_layout() {
5002 let zero = parse_mathml(r#"<math><mo lspace="0em" rspace="0em">+</mo></math>"#).unwrap();
5006 let neg =
5007 parse_mathml(r#"<math><mo lspace="-0.15em" rspace="-0.15em">+</mo></math>"#).unwrap();
5008 let zero_layout = layout_math(&zero, 16.0, MathDisplay::Inline);
5009 let neg_layout = layout_math(&neg, 16.0, MathDisplay::Inline);
5010 let expected = 2.0 * 0.15 * 16.0;
5011 assert!(
5012 (zero_layout.width - neg_layout.width - expected).abs() < 1e-3,
5013 "negative spacing should remove {expected}px: zero={} neg={}",
5014 zero_layout.width,
5015 neg_layout.width,
5016 );
5017 }
5018
5019 #[test]
5020 fn parses_mathml_fraction_with_scripts() {
5021 let expr = parse_mathml(
5022 r#"
5023 <math>
5024 <mfrac>
5025 <mrow>
5026 <msup><mi>a</mi><mn>2</mn></msup>
5027 <mo>+</mo>
5028 <msup><mi>b</mi><mn>2</mn></msup>
5029 </mrow>
5030 <msqrt>
5031 <msub><mi>x</mi><mn>1</mn></msub>
5032 <mo>+</mo>
5033 <msub><mi>x</mi><mn>2</mn></msub>
5034 </msqrt>
5035 </mfrac>
5036 </math>
5037 "#,
5038 )
5039 .expect("valid mathml");
5040 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
5041 assert!(layout.width > 20.0, "width = {}", layout.width);
5042 assert!(
5043 layout
5044 .atoms
5045 .iter()
5046 .any(|atom| matches!(atom, MathAtom::Rule { .. })),
5047 "fraction should emit rule atoms"
5048 );
5049 assert!(
5050 has_radical_shape(&layout),
5051 "sqrt should emit a radical shape atom"
5052 );
5053 }
5054
5055 #[test]
5056 fn parses_mathml_indexed_root() {
5057 let expr = parse_mathml(
5058 r#"
5059 <math>
5060 <mroot>
5061 <mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
5062 <mn>3</mn>
5063 </mroot>
5064 </math>
5065 "#,
5066 )
5067 .expect("valid mathml");
5068 match expr {
5069 MathExpr::Root { base, index } => {
5070 assert_eq!(*index, MathExpr::Number("3".into()));
5071 assert!(matches!(*base, MathExpr::Row(_)));
5072 }
5073 other => panic!("expected indexed root, got {other:?}"),
5074 }
5075 }
5076
5077 #[test]
5078 fn parses_mathml_under_over() {
5079 let expr = parse_mathml(
5080 r#"
5081 <math>
5082 <munderover>
5083 <mo>∑</mo>
5084 <mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
5085 <mi>n</mi>
5086 </munderover>
5087 </math>
5088 "#,
5089 )
5090 .expect("valid mathml");
5091 match expr {
5092 MathExpr::UnderOver { base, under, over } => {
5093 assert_eq!(*base, MathExpr::Operator("∑".into()));
5094 assert!(matches!(*under.unwrap(), MathExpr::Row(_)));
5095 assert_eq!(*over.unwrap(), MathExpr::Identifier("n".into()));
5096 }
5097 other => panic!("expected under/over expression, got {other:?}"),
5098 }
5099 }
5100
5101 #[test]
5102 fn parses_mathml_operator_spacing_attributes() {
5103 let expr = parse_mathml(r#"<math><mo lspace="0em" rspace="0.5em">+</mo></math>"#)
5104 .expect("valid spaced operator");
5105 assert_eq!(
5106 expr,
5107 MathExpr::OperatorWithMetadata {
5108 text: "+".into(),
5109 lspace: Some(0.0),
5110 rspace: Some(0.5),
5111 large_operator: None,
5112 movable_limits: None,
5113 }
5114 );
5115
5116 let default_width =
5117 layout_math(&MathExpr::Operator("+".into()), 16.0, MathDisplay::Inline).width;
5118 let custom_width = layout_math(&expr, 16.0, MathDisplay::Inline).width;
5119 assert!(
5120 custom_width > default_width,
5121 "custom width = {custom_width}, default width = {default_width}"
5122 );
5123 }
5124
5125 #[test]
5126 fn parses_mathml_operator_limit_attributes() {
5127 let expr = parse_mathml(
5128 r#"
5129 <math>
5130 <msub>
5131 <mo movablelimits="true">lim</mo>
5132 <mi>x</mi>
5133 </msub>
5134 </math>
5135 "#,
5136 )
5137 .expect("valid movable limits operator");
5138 let layout = layout_math(&expr, 16.0, MathDisplay::Block);
5139 assert!(
5140 layout
5141 .atoms
5142 .iter()
5143 .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "x" && *y_baseline > 0.0)),
5144 "movablelimits operator should place display subscript underneath"
5145 );
5146
5147 let large = parse_mathml(r#"<math><mo largeop="true">∫</mo></math>"#)
5148 .expect("valid large operator");
5149 assert!(matches!(
5150 large,
5151 MathExpr::OperatorWithMetadata {
5152 large_operator: Some(true),
5153 ..
5154 }
5155 ));
5156 }
5157
5158 #[test]
5159 fn parses_mathml_accent_mover() {
5160 let expr = parse_mathml(
5161 r#"
5162 <math>
5163 <mover accent="true">
5164 <mi>x</mi>
5165 <mo>^</mo>
5166 </mover>
5167 </math>
5168 "#,
5169 )
5170 .expect("valid mathml accent");
5171 match expr {
5172 MathExpr::Accent {
5173 base,
5174 accent,
5175 stretch,
5176 } => {
5177 assert_eq!(*base, MathExpr::Identifier("x".into()));
5178 assert_eq!(*accent, MathExpr::Operator("^".into()));
5179 assert!(!stretch);
5180 }
5181 other => panic!("expected accent expression, got {other:?}"),
5182 }
5183 }
5184
5185 #[test]
5186 fn parses_mathml_semantics_wrapper() {
5187 let expr = parse_mathml(
5188 r#"
5189 <math>
5190 <semantics>
5191 <mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
5192 <annotation encoding="application/x-tex">x+1</annotation>
5193 </semantics>
5194 </math>
5195 "#,
5196 )
5197 .expect("valid mathml semantics wrapper");
5198 match expr {
5199 MathExpr::Row(children) => {
5200 assert_eq!(children.len(), 3);
5201 assert_eq!(children[0], MathExpr::Identifier("x".into()));
5202 assert_eq!(children[2], MathExpr::Number("1".into()));
5203 }
5204 other => panic!("expected row expression, got {other:?}"),
5205 }
5206 }
5207
5208 #[test]
5209 fn rejects_mathml_semantics_without_presentation_child() {
5210 let err = parse_mathml(
5211 r#"
5212 <math>
5213 <semantics>
5214 <annotation encoding="application/x-tex">x+1</annotation>
5215 </semantics>
5216 </math>
5217 "#,
5218 )
5219 .expect_err("invalid mathml semantics wrapper");
5220 assert!(
5221 err.message
5222 .contains("<semantics> expected a presentation child")
5223 );
5224 }
5225
5226 #[test]
5227 fn parses_mathml_fenced_expression() {
5228 let expr = parse_mathml(
5229 r#"
5230 <math>
5231 <mfenced open="[" close="]" separators=",">
5232 <mi>a</mi>
5233 <mi>b</mi>
5234 </mfenced>
5235 </math>
5236 "#,
5237 )
5238 .expect("valid mathml fenced expression");
5239 match expr {
5240 MathExpr::Fenced { open, close, body } => {
5241 assert_eq!(open.as_deref(), Some("["));
5242 assert_eq!(close.as_deref(), Some("]"));
5243 match body.as_ref() {
5244 MathExpr::Row(children) => {
5245 assert_eq!(children.len(), 3);
5246 assert_eq!(children[1], MathExpr::Operator(",".into()));
5247 }
5248 other => panic!("expected row body, got {other:?}"),
5249 }
5250 }
5251 other => panic!("expected fenced expression, got {other:?}"),
5252 }
5253 }
5254
5255 #[test]
5256 fn parses_mathml_table() {
5257 let expr = parse_mathml(
5258 r#"
5259 <math>
5260 <mtable>
5261 <mtr>
5262 <mtd><mi>a</mi></mtd>
5263 <mtd><mi>b</mi></mtd>
5264 </mtr>
5265 <mtr>
5266 <mtd><mi>c</mi></mtd>
5267 <mtd><mi>d</mi></mtd>
5268 </mtr>
5269 </mtable>
5270 </math>
5271 "#,
5272 )
5273 .expect("valid mathml");
5274 match expr {
5275 MathExpr::Table { rows, .. } => {
5276 assert_eq!(rows.len(), 2);
5277 assert_eq!(rows[0].len(), 2);
5278 assert_eq!(rows[1].len(), 2);
5279 }
5280 other => panic!("expected table expression, got {other:?}"),
5281 }
5282 let layout = layout_math(
5283 &parse_mathml(
5284 r#"<math><mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable></math>"#,
5285 )
5286 .unwrap(),
5287 16.0,
5288 MathDisplay::Block,
5289 );
5290 assert!(layout.width > 20.0, "width = {}", layout.width);
5291 assert!(layout.ascent > 10.0, "ascent = {}", layout.ascent);
5292 assert!(layout.descent > 10.0, "descent = {}", layout.descent);
5293 }
5294
5295 #[test]
5296 fn parses_mathml_table_column_alignment() {
5297 let expr = parse_mathml(
5298 r#"
5299 <math>
5300 <mtable columnalign="left right">
5301 <mtr>
5302 <mtd><mi>x</mi></mtd>
5303 <mtd><mn>100</mn></mtd>
5304 </mtr>
5305 </mtable>
5306 </math>
5307 "#,
5308 )
5309 .expect("valid aligned mathml table");
5310 match expr {
5311 MathExpr::Table {
5312 column_alignments, ..
5313 } => {
5314 assert_eq!(
5315 column_alignments,
5316 vec![MathColumnAlignment::Left, MathColumnAlignment::Right]
5317 );
5318 }
5319 other => panic!("expected table expression, got {other:?}"),
5320 }
5321 }
5322
5323 #[test]
5324 fn parses_mathml_table_spacing() {
5325 let expr = parse_mathml(
5326 r#"
5327 <math>
5328 <mtable columnspacing="0.5em" rowspacing="0.2em">
5329 <mtr>
5330 <mtd><mi>a</mi></mtd>
5331 <mtd><mi>b</mi></mtd>
5332 </mtr>
5333 <mtr>
5334 <mtd><mi>c</mi></mtd>
5335 <mtd><mi>d</mi></mtd>
5336 </mtr>
5337 </mtable>
5338 </math>
5339 "#,
5340 )
5341 .expect("valid spaced mathml table");
5342 match expr {
5343 MathExpr::Table {
5344 column_gap,
5345 row_gap,
5346 ..
5347 } => {
5348 assert_eq!(column_gap, Some(0.5));
5349 assert_eq!(row_gap, Some(0.2));
5350 }
5351 other => panic!("expected table expression, got {other:?}"),
5352 }
5353 }
5354
5355 #[test]
5356 fn parses_mathml_display_attribute() {
5357 let (expr, display) = parse_mathml_with_display(
5358 r#"<math display="block"><msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup></math>"#,
5359 )
5360 .expect("valid mathml");
5361 assert_eq!(display, MathDisplay::Block);
5362 match expr {
5363 MathExpr::Scripts { base, sub, sup } => {
5364 assert_eq!(*base, MathExpr::Identifier("x".into()));
5365 assert_eq!(*sub.unwrap(), MathExpr::Number("1".into()));
5366 assert_eq!(*sup.unwrap(), MathExpr::Number("2".into()));
5367 }
5368 other => panic!("expected scripts expression, got {other:?}"),
5369 }
5370 }
5371
5372 #[test]
5373 fn rejects_wrong_mathml_arity() {
5374 let err =
5375 parse_mathml(r#"<math><mfrac><mi>a</mi></mfrac></math>"#).expect_err("invalid arity");
5376 assert!(err.message.contains("expected 2 element children"));
5377 }
5378}