1//! Two-dimensional layout for equations, from the tree `onestore::page::Math` parses out of
2//! the linear text OneNote stores.
3
4use crate::layout::{LayoutError, TextEngine, TextLayout};
5use onestore::document::Format;
6use onestore::page::Math;
7use onestore::page::text::Paragraph;
8
9/// An equation laid out for drawing; coordinates are relative to its top-left.
10#[derive(Clone)]
11pub struct MathLayout {
12 pub size: [f32; 2],
13 /// COLORREF for rules and strokes, or `None` for the paper's ink.
14 pub(crate) color: Option<u32>,
15 /// Distance from the top to the baseline of the equation's main row.
16 pub baseline: f32,
17 pub(crate) items: Vec<MathItem>,
18}
19
20#[derive(Clone)]
21pub(crate) enum MathItem {
22 Text {
23 layout: TextLayout,
24 origin: [f32; 2],
25 },
26 Rule([f32; 4]),
27 /// A pen stroke, for radical signs.
28 Stroke {
29 from: [f32; 2],
30 to: [f32; 2],
31 width: f32,
32 },
33}
34
35/// Items are relative to the box origin on its baseline; y grows downward.
36struct Bx {
37 width: f32,
38 ascent: f32,
39 descent: f32,
40 items: Vec<MathItem>,
41}
42
43impl Bx {
44 fn empty() -> Self {
45 Self {
46 width: 0.0,
47 ascent: 0.0,
48 descent: 0.0,
49 items: Vec::new(),
50 }
51 }
52
53 /// Places `other` with its baseline origin at `at` in this box's coordinates.
54 fn place(&mut self, other: Bx, at: [f32; 2]) {
55 self.ascent = self.ascent.max(other.ascent - at[1]);
56 self.descent = self.descent.max(other.descent + at[1]);
57 self.width = self.width.max(at[0] + other.width);
58 self.items
59 .extend(other.items.into_iter().map(|item| offset(item, at)));
60 }
61
62 fn append(&mut self, other: Bx) {
63 let x = self.width;
64 self.place(other, [x, 0.0]);
65 }
66
67 fn space(&mut self, width: f32) {
68 self.width += width;
69 }
70
71 fn height(&self) -> f32 {
72 self.ascent + self.descent
73 }
74}
75
76fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem {
77 match item {
78 MathItem::Text { layout, origin } => MathItem::Text {
79 layout,
80 origin: [origin[0] + dx, origin[1] + dy],
81 },
82 MathItem::Rule(rect) => MathItem::Rule(crate::translated(rect, [dx, dy])),
83 MathItem::Stroke { from, to, width } => MathItem::Stroke {
84 from: [from[0] + dx, from[1] + dy],
85 to: [to[0] + dx, to[1] + dy],
86 width,
87 },
88 }
89}
90
91const SCRIPT: f32 = 0.7;
92const AXIS: f32 = 0.25;
93const RULE: f32 = 0.06;
94
95struct Context<'a> {
96 engine: &'a mut TextEngine,
97 color: Option<u32>,
98}
99
100impl Context<'_> {
101 fn text(&mut self, text: &str, size: f32) -> Result<Bx, LayoutError> {
102 let layout = self.engine.layout(
103 &Paragraph::new(
104 text.into(),
105 Format {
106 font: Some("Cambria Math".into()),
107 font_size: Some(size),
108 color: self.color,
109 ..Format::default()
110 },
111 ),
112 // Atoms never wrap.
113 f32::MAX / 4.0,
114 )?;
115 let (line, bounds) = layout
116 .lines()
117 .next()
118 .ok_or(LayoutError::InvalidFontMetrics)?;
119 let (width, baseline) = (line.metrics().advance, bounds.baseline);
120 // Math fonts' line boxes span their tallest glyphs; placement follows the ink instead.
121 let [ascent, descent] = ink(&layout);
122 Ok(Bx {
123 width,
124 ascent,
125 descent,
126 items: vec![MathItem::Text {
127 layout,
128 origin: [0.0, -baseline],
129 }],
130 })
131 }
132
133 fn row(&mut self, nodes: &[Math], size: f32) -> Result<Bx, LayoutError> {
134 let mut result = Bx::empty();
135 for (index, node) in nodes.iter().enumerate() {
136 let spaced = match node {
137 Math::Operator(c) if index > 0 => spacing(*c),
138 _ => 0.0,
139 } * size;
140 result.space(spaced);
141 result.append(self.node(node, size)?);
142 if index + 1 < nodes.len() {
143 result.space(spaced);
144 }
145 }
146 Ok(result)
147 }
148
149 fn node(&mut self, node: &Math, size: f32) -> Result<Bx, LayoutError> {
150 match node {
151 Math::Identifier(c) => self.text(&italic(*c).to_string(), size),
152 Math::Function(name) | Math::Number(name) => self.text(name, size),
153 Math::Operator(' ') => {
154 let mut space = Bx::empty();
155 space.space(0.2 * size);
156 Ok(space)
157 }
158 Math::Operator(c) => self.text(&c.to_string(), size),
159 Math::Object {
160 kind,
161 symbols,
162 columns,
163 arguments,
164 } => self.object(*kind, symbols, *columns, arguments, size),
165 }
166 }
167
168 fn argument(
169 &mut self,
170 arguments: &[Vec<Math>],
171 index: usize,
172 size: f32,
173 ) -> Result<Bx, LayoutError> {
174 self.row(arguments.get(index).map_or(&[][..], Vec::as_slice), size)
175 }
176
177 fn object(
178 &mut self,
179 kind: u32,
180 symbols: &[char],
181 columns: Option<u8>,
182 arguments: &[Vec<Math>],
183 size: f32,
184 ) -> Result<Bx, LayoutError> {
185 let script = size * SCRIPT;
186 Ok(match (kind, arguments.len()) {
187 (31, 2) => self.scripts(None, Some(1), arguments, size)?,
188 (29, 2) => self.scripts(Some(1), None, arguments, size)?,
189 (30, 3) => self.scripts(Some(1), Some(2), arguments, size)?,
190 (16, 2) | (26, 2) => {
191 let numerator = self.argument(arguments, 0, size)?;
192 let denominator = self.argument(arguments, 1, size)?;
193 let width = numerator.width.max(denominator.width) + 0.2 * size;
194 let (axis, rule, gap) = (AXIS * size, RULE * size, 0.12 * size);
195 let mut result = Bx::empty();
196 let top = -axis - rule / 2.0 - gap - numerator.descent;
197 let bottom = -axis + rule / 2.0 + gap + denominator.ascent;
198 result.place(numerator.centered(width), [0.0, top]);
199 result.place(denominator.centered(width), [0.0, bottom]);
200 result.items.push(MathItem::Rule([
201 0.0,
202 -axis - rule / 2.0,
203 width,
204 -axis + rule / 2.0,
205 ]));
206 result
207 }
208 (25, _) => {
209 let body = self.argument(arguments, arguments.len().saturating_sub(1), size)?;
210 let degree = (arguments.len() == 2 && !arguments[0].is_empty())
211 .then(|| self.row(&arguments[0], size * 0.5))
212 .transpose()?;
213 radical(body, degree, size)
214 }
215 (19, 2) | (33, 2) => {
216 let base = self.argument(arguments, 0, size)?;
217 let limit = self.argument(arguments, 1, script)?;
218 if kind == 19 {
219 stacked(base, Some(limit), None, size)
220 } else {
221 stacked(base, None, Some(limit), size)
222 }
223 }
224 (13, _) => {
225 let body = self.argument(arguments, 0, size)?;
226 let open = symbols
227 .first()
228 .map(|c| self.fence(*c, &body, size))
229 .transpose()?;
230 let close = symbols
231 .get(1)
232 .map(|c| self.fence(*c, &body, size))
233 .transpose()?;
234 let mut result = Bx::empty();
235 for part in [open, Some(body), close].into_iter().flatten() {
236 result.append(part);
237 }
238 result
239 }
240 (10, 1) => {
241 let base = self.argument(arguments, 0, size)?;
242 let accent = self.text(
243 &spacing_accent(symbols.first().copied().unwrap_or('\u{302}')).to_string(),
244 size,
245 )?;
246 let mut result = Bx::empty();
247 let width = base.width;
248 // The accent's ink bottom sits just above the base's ink top.
249 let lift = -base.ascent - 0.05 * size - accent.descent;
250 result.place(base, [0.0, 0.0]);
251 let x = (width - accent.width) / 2.0;
252 result.place(accent, [x.max(0.0), lift]);
253 result
254 }
255 (23, 1) => {
256 let base = self.argument(arguments, 0, size)?;
257 let (rule, gap) = (RULE * size, 0.1 * size);
258 let mut result = Bx::empty();
259 let top = -base.ascent - gap;
260 let width = base.width;
261 result.place(base, [0.0, 0.0]);
262 result
263 .items
264 .push(MathItem::Rule([0.0, top - rule, width, top]));
265 result.ascent = result.ascent.max(-(top - rule));
266 result
267 }
268 (12, 1) => {
269 let body = self.argument(arguments, 0, size)?;
270 let (rule, pad) = (RULE * size, 0.12 * size);
271 let mut result = Bx::empty();
272 let [w, a, d] = [
273 body.width + 2.0 * pad,
274 body.ascent + pad,
275 body.descent + pad,
276 ];
277 result.place(body, [pad, 0.0]);
278 for rect in [
279 [0.0, -a, w, -a + rule],
280 [0.0, d - rule, w, d],
281 [0.0, -a, rule, d],
282 [w - rule, -a, w, d],
283 ] {
284 result.items.push(MathItem::Rule(rect));
285 }
286 result.ascent = result.ascent.max(a);
287 result.descent = result.descent.max(d);
288 result.width = w;
289 result
290 }
291 (21, 3) => {
292 let operator = symbols.first().copied().unwrap_or('∑');
293 let integral = ('\u{222b}'..='\u{2233}').contains(&operator);
294 // Display-size operators, as OneNote draws them.
295 let scale = if integral { 1.9 } else { 1.75 };
296 let glyph = self
297 .text(&operator.to_string(), size * scale)?
298 .on_axis(size);
299 let lower = (!arguments[0].is_empty())
300 .then(|| self.row(&arguments[0], script))
301 .transpose()?;
302 let upper = (!arguments[1].is_empty())
303 .then(|| self.row(&arguments[1], script))
304 .transpose()?;
305 let mut result = if integral {
306 let mut result = Bx::empty();
307 let (ascent, descent, width) = (glyph.ascent, glyph.descent, glyph.width);
308 result.append(glyph);
309 let x = width + 0.05 * size;
310 if let Some(upper) = upper {
311 let y = -ascent + upper.ascent;
312 result.place(upper, [x, y]);
313 }
314 if let Some(lower) = lower {
315 let y = descent - lower.descent;
316 result.place(lower, [x - 0.2 * size, y]);
317 }
318 result
319 } else {
320 stacked(glyph, lower, upper, size)
321 };
322 result.space(0.15 * size);
323 result.append(self.argument(arguments, 2, size)?);
324 result
325 }
326 (20, _) | (15, _) => {
327 let width = if kind == 20 {
328 usize::from(columns.unwrap_or(1).max(1))
329 } else {
330 1
331 };
332 let cells = arguments
333 .iter()
334 .map(|cell| {
335 let cell: Vec<Math> = cell
336 .iter()
337 .filter(|n| **n != Math::Operator('&'))
338 .cloned()
339 .collect();
340 self.row(&cell, size)
341 })
342 .collect::<Result<Vec<_>, _>>()?;
343 grid(cells, width, kind == 20, size)
344 }
345 (11, 1) => self.argument(arguments, 0, size)?,
346 _ => {
347 let mut result = Bx::empty();
348 for symbol in symbols {
349 result.append(self.text(&symbol.to_string(), size)?);
350 }
351 for index in 0..arguments.len() {
352 result.append(self.argument(arguments, index, size)?);
353 }
354 result
355 }
356 })
357 }
358
359 /// The first argument with the arguments at `lower` and `upper` as its scripts.
360 fn scripts(
361 &mut self,
362 lower: Option<usize>,
363 upper: Option<usize>,
364 arguments: &[Vec<Math>],
365 size: f32,
366 ) -> Result<Bx, LayoutError> {
367 let script = size * SCRIPT;
368 let mut result = self.argument(arguments, 0, size)?;
369 let x = result.width + 0.03 * size;
370 let both = lower.is_some() && upper.is_some();
371 if let Some(index) = upper {
372 let upper = self.argument(arguments, index, script)?;
373 result.place(upper, [x, -0.4 * size]);
374 }
375 if let Some(index) = lower {
376 let lower = self.argument(arguments, index, script)?;
377 result.place(lower, [x, if both { 0.3 } else { 0.2 } * size]);
378 }
379 Ok(result)
380 }
381
382 /// A delimiter tall enough for `body`, centered on the math axis.
383 fn fence(&mut self, symbol: char, body: &Bx, size: f32) -> Result<Bx, LayoutError> {
384 let axis = AXIS * size;
385 let reach = (body.ascent - axis).max(body.descent + axis);
386 let grown = (reach * 2.0 / (size * 1.15)).max(1.0) * size;
387 Ok(self.text(&symbol.to_string(), grown)?.on_axis(size))
388 }
389}
390
391impl Bx {
392 /// This box moved vertically so its extent centers on the math axis.
393 fn on_axis(self, size: f32) -> Bx {
394 let shift = (self.ascent - self.descent) / 2.0 - AXIS * size;
395 Bx {
396 width: self.width,
397 ascent: self.ascent - shift,
398 descent: self.descent + shift,
399 items: self
400 .items
401 .into_iter()
402 .map(|i| offset(i, [0.0, shift]))
403 .collect(),
404 }
405 }
406
407 /// This box horizontally centered in `width`.
408 fn centered(self, width: f32) -> Bx {
409 let dx = (width - self.width) / 2.0;
410 Bx {
411 width,
412 ascent: self.ascent,
413 descent: self.descent,
414 items: self
415 .items
416 .into_iter()
417 .map(|i| offset(i, [dx, 0.0]))
418 .collect(),
419 }
420 }
421}
422
423/// `base` with `lower` below and `upper` above, all centered on the widest.
424fn stacked(base: Bx, lower: Option<Bx>, upper: Option<Bx>, size: f32) -> Bx {
425 let gap = 0.08 * size;
426 let width = [Some(&base), lower.as_ref(), upper.as_ref()]
427 .into_iter()
428 .flatten()
429 .map(|b| b.width)
430 .fold(0.0, f32::max);
431 let (ascent, descent) = (base.ascent, base.descent);
432 let mut result = Bx::empty();
433 result.place(base.centered(width), [0.0, 0.0]);
434 if let Some(lower) = lower {
435 let y = descent + gap + lower.ascent;
436 result.place(lower.centered(width), [0.0, y]);
437 }
438 if let Some(upper) = upper {
439 let y = -ascent - gap - upper.descent;
440 result.place(upper.centered(width), [0.0, y]);
441 }
442 result
443}
444
445/// A radical sign drawn with the pen over `body`, with an optional small `degree`.
446fn radical(body: Bx, degree: Option<Bx>, size: f32) -> Bx {
447 let (rule, gap) = (RULE * size, 0.12 * size);
448 let top = -body.ascent - gap;
449 let bottom = body.descent;
450 let sign = 0.55 * size;
451 let mut result = Bx::empty();
452 let lead = degree
453 .as_ref()
454 .map_or(0.0, |d| (d.width - sign * 0.4).max(0.0));
455 let [x0, x1, x2] = [lead, lead + sign * 0.35, lead + sign * 0.6];
456 let mid = bottom - (bottom - top) * 0.45;
457 for (from, to, width) in [
458 ([x0, mid + 0.05 * size], [x0 + sign * 0.12, mid], rule),
459 ([x0 + sign * 0.12, mid], [x1, bottom], rule * 1.6),
460 ([x1, bottom], [x2, top + rule / 2.0], rule),
461 ] {
462 result.items.push(MathItem::Stroke { from, to, width });
463 }
464 let body_width = body.width;
465 result.place(body, [x2 + 0.05 * size, 0.0]);
466 result.items.push(MathItem::Rule([
467 x2,
468 top,
469 x2 + 0.05 * size + body_width + 0.05 * size,
470 top + rule,
471 ]));
472 result.width = x2 + 0.1 * size + body_width;
473 result.ascent = result.ascent.max(-top);
474 result.descent = result.descent.max(bottom);
475 if let Some(degree) = degree {
476 let y = mid - 0.1 * size - degree.descent;
477 result.place(degree, [0.0, y]);
478 }
479 result
480}
481
482/// Cells in rows of `width` columns, centered in each column (left-aligned for equation
483/// arrays), the whole grid centered on the math axis.
484fn grid(cells: Vec<Bx>, width: usize, centered: bool, size: f32) -> Bx {
485 let (column_gap, row_gap) = (0.8 * size, 0.2 * size);
486 let mut columns = vec![0.0_f32; width];
487 for (index, cell) in cells.iter().enumerate() {
488 columns[index % width] = columns[index % width].max(cell.width);
489 }
490 let rows: Vec<(f32, f32)> = cells
491 .chunks(width)
492 .map(|row| {
493 row.iter().fold((0.0_f32, 0.0_f32), |(a, d), cell| {
494 (a.max(cell.ascent), d.max(cell.descent))
495 })
496 })
497 .collect();
498 let height: f32 = rows.iter().map(|(a, d)| a + d).sum::<f32>()
499 + row_gap * rows.len().saturating_sub(1) as f32;
500 let mut y = -AXIS * size - height / 2.0;
501 let mut result = Bx::empty();
502 let mut cells = cells.into_iter();
503 for (ascent, descent) in rows {
504 let mut x = 0.0;
505 for column in &columns {
506 let Some(cell) = cells.next() else { break };
507 let dx = if centered {
508 (column - cell.width) / 2.0
509 } else {
510 0.0
511 };
512 result.place(cell, [x + dx, y + ascent]);
513 x += column + column_gap;
514 }
515 y += ascent + descent + row_gap;
516 }
517 result.width = columns.iter().sum::<f32>() + column_gap * width.saturating_sub(1) as f32;
518 result
519}
520
521/// Extra space each side of an operator, in em: medium for binary operators, thick for
522/// relations and arrows.
523fn spacing(c: char) -> f32 {
524 match c {
525 '+' | '-' | '−' | '±' | '∓' | '×' | '÷' | '·' | '∙' | '*' => 4.0 / 18.0,
526 '=' | '<' | '>' | '≤' | '≥' | '≠' | '≈' | '≡' | '→' | '←' | '↔' | '⇒' | '∈' | '∉' => {
527 5.0 / 18.0
528 }
529 _ => 0.0,
530 }
531}
532
533/// The spacing form of a combining accent, drawn above its base.
534fn spacing_accent(c: char) -> char {
535 match c {
536 '\u{302}' => 'ˆ',
537 '\u{303}' => '˜',
538 '\u{307}' => '˙',
539 '\u{308}' => '¨',
540 '\u{301}' => '´',
541 '\u{300}' => '`',
542 '\u{306}' => '˘',
543 '\u{30c}' => 'ˇ',
544 '\u{20d7}' => '→',
545 c => c,
546 }
547}
548
549/// Latin letters draw in mathematical italic, as OneNote's equation editor stores them.
550fn italic(c: char) -> char {
551 match c {
552 'h' => '\u{210e}',
553 'a'..='z' => char::from_u32(0x1d44e + (c as u32 - 'a' as u32)).unwrap(),
554 'A'..='Z' => char::from_u32(0x1d434 + (c as u32 - 'A' as u32)).unwrap(),
555 c => c,
556 }
557}
558
559/// Height above and depth below the baseline of the glyphs' outlines; the depth is negative
560/// for ink that floats above the baseline, as an accent's does.
561fn ink(layout: &TextLayout) -> [f32; 2] {
562 use skrifa::{MetadataProvider, instance::Size};
563 let mut extent = [f32::NEG_INFINITY; 2];
564 for line in layout.shaped.lines() {
565 for item in line.items() {
566 let parley::PositionedLayoutItem::GlyphRun(glyphs) = item else {
567 continue;
568 };
569 let run = glyphs.run();
570 let font = &run.font().font;
571 let Ok(font) = skrifa::FontRef::from_index(font.data.as_ref(), font.index) else {
572 continue;
573 };
574 let metrics = font.glyph_metrics(
575 Size::new(run.font_size()),
576 skrifa::instance::LocationRef::default(),
577 );
578 for glyph in glyphs.glyphs() {
579 match metrics.bounds(skrifa::GlyphId::new(glyph.id)) {
580 // Blank glyphs, such as spaces, have no ink.
581 Some(bounds) if bounds.y_max > bounds.y_min => {
582 extent[0] = extent[0].max(bounds.y_max - glyph.y);
583 extent[1] = extent[1].max(glyph.y - bounds.y_min);
584 }
585 _ => {}
586 }
587 }
588 }
589 }
590 extent.map(|v| if v.is_finite() { v } else { 0.0 })
591}
592
593/// The byte ranges of `paragraph`'s runs of math holding objects, which draw in two
594/// dimensions; math without objects is text.
595pub(crate) fn built(paragraph: &Paragraph) -> Vec<std::ops::Range<usize>> {
596 let mut zones: Vec<std::ops::Range<usize>> = Vec::new();
597 let mut start = 0;
598 for span in paragraph.spans() {
599 if span.format.math == Some(true) {
600 match zones.last_mut() {
601 Some(zone) if zone.end == start => zone.end = span.end,
602 _ => zones.push(start..span.end),
603 }
604 }
605 start = span.end;
606 }
607 zones.retain(|zone| paragraph.text()[zone.clone()].contains('\u{fdd0}'));
608 zones
609}
610
611/// Lays out an equation paragraph at the font size and colour of its first run.
612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
614 let format = &paragraph.spans()[0].format;
615 let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE);
616 let color = format.color.filter(|color| *color != 0xff00_0000);
617 // An equation line is at least as tall as a line of text at its size.
618 let strut = engine.layout(
619 &Paragraph::new(
620 " ".into(),
621 Format {
622 font_size: Some(size),
623 ..Format::default()
624 },
625 ),
626 100.0,
627 )?;
628 let (_, line) = strut
629 .lines()
630 .next()
631 .ok_or(LayoutError::InvalidFontMetrics)?;
632 let mut body = Context { engine, color }.row(&nodes, size)?;
633 // Boxes follow ink; lines with taller math keep a gap from the lines beside them.
634 let gap = 0.15 * size;
635 body.ascent = (body.ascent + gap).max(line.baseline);
636 body.descent = (body.descent + gap).max(line.height - line.baseline);
637 Ok(MathLayout {
638 size: [body.width, body.height()],
639 color,
640 baseline: body.ascent,
641 items: body
642 .items
643 .into_iter()
644 .map(|item| offset(item, [0.0, body.ascent]))
645 .collect(),
646 })
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 fn object(kind: u32, symbols: &[char], arguments: Vec<Vec<Math>>) -> Math {
654 Math::Object {
655 kind,
656 symbols: symbols.to_vec(),
657 columns: None,
658 arguments,
659 }
660 }
661
662 /// Each text's left edge and baseline.
663 fn texts(layout: &MathLayout) -> Vec<[f32; 2]> {
664 layout
665 .items
666 .iter()
667 .filter_map(|item| match item {
668 MathItem::Text { origin, layout } => Some([
669 origin[0],
670 origin[1] + layout.lines().next().unwrap().1.baseline,
671 ]),
672 _ => None,
673 })
674 .collect()
675 }
676
677 fn laid_out(nodes: &[Math]) -> MathLayout {
678 let mut engine = TextEngine::default();
679 layout(&mut engine, &Math::paragraph(nodes, &Format::default())).unwrap()
680 }
681
682 #[test]
683 fn scripts_fractions_and_accents_stack_as_equations_do() {
684 let x = || vec![Math::Identifier('x')];
685 let scripted = laid_out(&[object(
686 30,
687 &[],
688 vec![
689 x(),
690 vec![Math::Identifier('i')],
691 vec![Math::Number("2".into())],
692 ],
693 )]);
694 let [base, upper, lower] = texts(&scripted)[..] else {
695 panic!()
696 };
697 assert!(lower[0] > base[0] && upper[0] == lower[0]);
698 assert!(upper[1] < base[1] && lower[1] > base[1]);
699
700 let fraction = laid_out(&[object(16, &[], vec![x(), vec![Math::Identifier('y')]])]);
701 let [numerator, denominator] = texts(&fraction)[..] else {
702 panic!()
703 };
704 let bar = fraction.items.iter().find_map(|item| match item {
705 MathItem::Rule(rect) => Some(*rect),
706 _ => None,
707 });
708 let [_, top, _, bottom] = bar.unwrap();
709 assert!(numerator[1] < top && denominator[1] > numerator[1] && bottom > top);
710 assert!(fraction.size[1] > laid_out(&[Math::Identifier('x')]).size[1]);
711
712 let accented = laid_out(&[object(10, &['\u{302}'], vec![x()])]);
713 let [base, accent] = texts(&accented)[..] else {
714 panic!()
715 };
716 assert!(accent[1] < base[1]);
717
718 let radical = laid_out(&[object(25, &[], vec![vec![], x()])]);
719 assert!(
720 radical
721 .items
722 .iter()
723 .any(|item| matches!(item, MathItem::Stroke { .. }))
724 );
725 assert!(
726 radical
727 .items
728 .iter()
729 .any(|item| matches!(item, MathItem::Rule(_)))
730 );
731 }
732
733 #[test]
734 fn a_plain_equation_keeps_the_height_of_a_line_of_text() {
735 let mut engine = TextEngine::default();
736 let line = engine
737 .layout(&Paragraph::new("x".into(), Format::default()), 100.0)
738 .unwrap();
739 let equation = laid_out(&[
740 Math::Identifier('x'),
741 Math::Operator('+'),
742 Math::Number("1".into()),
743 ]);
744 assert!((equation.size[1] - line.height()).abs() < 0.01);
745 assert!(equation.size[0] > 0.0);
746 }
747}