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