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