1#![cfg(feature = "font")]
12
13use std::collections::HashMap;
14use std::sync::{Mutex, OnceLock};
15
16use ttf_parser::{Face, GlyphId, OutlineBuilder, Rect};
17
18use crate::geom::Shape;
19use crate::metrics::{SCRIPT_SCALE, STAR_FRAC};
20
21const BEZIER_STEPS: usize = 8;
22
23const FONT_REGULAR: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
24const FONT_BOLD: &[u8] = include_bytes!("../fonts/LiberationSans-Bold.ttf");
25const FONT_ITALIC: &[u8] = include_bytes!("../fonts/LiberationSans-Italic.ttf");
26const FONT_BOLD_ITALIC: &[u8] = include_bytes!("../fonts/LiberationSans-BoldItalic.ttf");
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30pub enum FaceStyle {
31 #[default]
32 Regular,
33 Bold,
34 Italic,
35 BoldItalic,
36}
37
38impl FaceStyle {
39 pub fn from_flags(bold: bool, italic: bool) -> Self {
40 match (bold, italic) {
41 (true, true) => Self::BoldItalic,
42 (true, false) => Self::Bold,
43 (false, true) => Self::Italic,
44 (false, false) => Self::Regular,
45 }
46 }
47
48 fn bytes(self) -> &'static [u8] {
49 match self {
50 Self::Regular => FONT_REGULAR,
51 Self::Bold => FONT_BOLD,
52 Self::Italic => FONT_ITALIC,
53 Self::BoldItalic => FONT_BOLD_ITALIC,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy)]
60pub struct FaceMetrics {
61 pub upem: f64,
62 pub ascent: f64,
63 pub descent: f64,
64 pub line_gap: f64,
65 pub cap_height: f64,
66 pub x_height: f64,
67 pub stem_em: f64,
68}
69
70#[derive(Debug, Clone, Copy)]
72pub struct GlyphMetrics {
73 pub advance: f64,
74 pub ink_xmin: Option<f64>,
75 pub ink_ymin: Option<f64>,
76 pub ink_xmax: Option<f64>,
77 pub ink_ymax: Option<f64>,
78}
79
80impl GlyphMetrics {
81 pub fn has_ink(&self) -> bool {
82 self.ink_xmin.is_some()
83 }
84
85 pub fn ink_width(&self) -> f64 {
86 match (self.ink_xmin, self.ink_xmax) {
87 (Some(a), Some(b)) => b - a,
88 _ => 0.0,
89 }
90 }
91
92 pub fn ink_height(&self) -> f64 {
93 match (self.ink_ymin, self.ink_ymax) {
94 (Some(a), Some(b)) => b - a,
95 _ => 0.0,
96 }
97 }
98}
99
100fn face(style: FaceStyle) -> Face<'static> {
101 Face::parse(style.bytes(), 0).expect("bundled Liberation Sans TTF")
102}
103
104pub fn face_metrics(style: FaceStyle) -> FaceMetrics {
106 let f = face(style);
107 let upem = f64::from(f.units_per_em());
108 let mut cap = f.capital_height().map(f64::from).unwrap_or(0.0);
109 let mut xh = f.x_height().map(f64::from).unwrap_or(0.0);
110 if cap <= 0.0 {
111 if let Some(g) = glyph_metrics('H', style) {
112 cap = g.ink_ymax.unwrap_or(0.0);
113 }
114 }
115 if xh <= 0.0 {
116 if let Some(g) = glyph_metrics('x', style) {
117 xh = g.ink_ymax.unwrap_or(0.0);
118 }
119 }
120 FaceMetrics {
121 upem,
122 ascent: f64::from(f.ascender()),
123 descent: f64::from(f.descender()),
124 line_gap: f64::from(f.line_gap()),
125 cap_height: cap,
126 x_height: xh,
127 stem_em: measure_stem_em(style),
128 }
129}
130
131pub fn glyph_metrics(ch: char, style: FaceStyle) -> Option<GlyphMetrics> {
133 let f = face(style);
134 let upem = f64::from(f.units_per_em());
135 let Some(gid) = f.glyph_index(ch) else {
136 return Some(GlyphMetrics {
137 advance: 0.5 * upem,
138 ink_xmin: None,
139 ink_ymin: None,
140 ink_xmax: None,
141 ink_ymax: None,
142 });
143 };
144 let advance = f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
145 let ink = f.glyph_bounding_box(gid).map(rect_to_ink);
146 Some(GlyphMetrics {
147 advance,
148 ink_xmin: ink.map(|r| r.0),
149 ink_ymin: ink.map(|r| r.1),
150 ink_xmax: ink.map(|r| r.2),
151 ink_ymax: ink.map(|r| r.3),
152 })
153}
154
155fn rect_to_ink(r: Rect) -> (f64, f64, f64, f64) {
156 (
157 f64::from(r.x_min),
158 f64::from(r.y_min),
159 f64::from(r.x_max),
160 f64::from(r.y_max),
161 )
162}
163
164struct ContourCollector {
165 contours: Vec<Vec<(f64, f64)>>,
166 cur: Vec<(f64, f64)>,
167}
168
169impl ContourCollector {
170 fn new() -> Self {
171 Self {
172 contours: Vec::new(),
173 cur: Vec::new(),
174 }
175 }
176
177 fn finish_contour(&mut self) {
178 if self.cur.len() >= 3 {
179 self.contours.push(std::mem::take(&mut self.cur));
180 } else {
181 self.cur.clear();
182 }
183 }
184}
185
186impl OutlineBuilder for ContourCollector {
187 fn move_to(&mut self, x: f32, y: f32) {
188 self.finish_contour();
189 self.cur = vec![(f64::from(x), f64::from(y))];
190 }
191
192 fn line_to(&mut self, x: f32, y: f32) {
193 self.cur.push((f64::from(x), f64::from(y)));
194 }
195
196 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
197 let (x0, y0) = *self.cur.last().expect("quad_to without move_to");
198 let (x1, y1, x, y) = (f64::from(x1), f64::from(y1), f64::from(x), f64::from(y));
199 for i in 1..=BEZIER_STEPS {
200 let t = i as f64 / BEZIER_STEPS as f64;
201 let mt = 1.0 - t;
202 self.cur.push((
203 mt * mt * x0 + 2.0 * mt * t * x1 + t * t * x,
204 mt * mt * y0 + 2.0 * mt * t * y1 + t * t * y,
205 ));
206 }
207 }
208
209 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
210 let (x0, y0) = *self.cur.last().expect("curve_to without move_to");
211 let (x1, y1, x2, y2, x, y) = (
212 f64::from(x1),
213 f64::from(y1),
214 f64::from(x2),
215 f64::from(y2),
216 f64::from(x),
217 f64::from(y),
218 );
219 for i in 1..=BEZIER_STEPS {
220 let t = i as f64 / BEZIER_STEPS as f64;
221 let mt = 1.0 - t;
222 self.cur.push((
223 mt * mt * mt * x0
224 + 3.0 * mt * mt * t * x1
225 + 3.0 * mt * t * t * x2
226 + t * t * t * x,
227 mt * mt * mt * y0
228 + 3.0 * mt * mt * t * y1
229 + 3.0 * mt * t * t * y2
230 + t * t * t * y,
231 ));
232 }
233 }
234
235 fn close(&mut self) {
236 self.finish_contour();
237 }
238}
239
240fn outline_glyph(face: &Face<'_>, gid: GlyphId) -> Vec<Vec<(f64, f64)>> {
241 let mut collector = ContourCollector::new();
242 let _ = face.outline_glyph(gid, &mut collector);
243 collector.finish_contour();
244 collector.contours
245}
246
247pub fn outline_run_em(text: &str, style: FaceStyle) -> (Option<Shape>, f64) {
249 if text.is_empty() {
250 return (None, 0.0);
251 }
252 let f = face(style);
253 let upem = f64::from(f.units_per_em());
254 let mut contours: Vec<Vec<(f64, f64)>> = Vec::new();
255 let mut pen_x = 0.0;
256 for ch in text.chars() {
257 let Some(gid) = f.glyph_index(ch) else {
258 pen_x += 0.5 * upem;
259 continue;
260 };
261 for mut contour in outline_glyph(&f, gid) {
262 for p in &mut contour {
263 p.0 += pen_x;
264 }
265 contours.push(contour);
266 }
267 pen_x += f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
268 }
269 if contours.is_empty() {
270 return (None, pen_x);
271 }
272 let shape = Shape::from_contours_evenodd(&contours);
273 if shape.is_empty() {
274 (None, pen_x)
275 } else {
276 (Some(shape), pen_x)
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub enum ScriptRole {
283 Normal,
284 Subscript,
285 Superscript,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct ChemGlyph {
291 pub ch: char,
292 pub role: ScriptRole,
293 pub face: FaceStyle,
294}
295
296fn script_cache() -> &'static Mutex<HashMap<(char, FaceStyle, ScriptRole), (Option<Shape>, f64)>> {
297 static CACHE: OnceLock<Mutex<HashMap<(char, FaceStyle, ScriptRole), (Option<Shape>, f64)>>> =
298 OnceLock::new();
299 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
300}
301
302pub fn outline_glyph_em(ch: char, style: FaceStyle, role: ScriptRole) -> (Option<Shape>, f64) {
307 if let Ok(guard) = script_cache().lock() {
308 if let Some(hit) = guard.get(&(ch, style, role)) {
309 return hit.clone();
310 }
311 }
312 let built = outline_glyph_em_uncached(ch, style, role);
313 if let Ok(mut guard) = script_cache().lock() {
314 guard.insert((ch, style, role), built.clone());
315 }
316 built
317}
318
319fn outline_glyph_em_uncached(
320 ch: char,
321 style: FaceStyle,
322 role: ScriptRole,
323) -> (Option<Shape>, f64) {
324 let f = face(style);
325 let upem = f64::from(f.units_per_em());
326 let Some(gid) = f.glyph_index(ch) else {
327 return (None, 0.5 * upem);
328 };
329 let advance = f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
330 let contours = outline_glyph(&f, gid);
331 if contours.is_empty() {
332 return (None, advance);
333 }
334 let shape = Shape::from_contours_evenodd(&contours);
335 if shape.is_empty() {
336 return (None, advance);
337 }
338 match role {
339 ScriptRole::Normal => (Some(shape), advance),
340 ScriptRole::Subscript | ScriptRole::Superscript => {
341 let scaled = shape.scale(SCRIPT_SCALE, SCRIPT_SCALE, 0.0, 0.0);
343 let stem = face_metrics(style).stem_em;
345 let grow = 0.5 * (1.0 - SCRIPT_SCALE) * stem * upem;
346 let thick = if grow > 0.05 {
347 scaled.buffer(grow)
348 } else {
349 scaled
350 };
351 (Some(thick), advance * SCRIPT_SCALE)
352 }
353 }
354}
355
356pub fn outline_star_em(style: FaceStyle) -> (Shape, f64) {
361 let face_m = face_metrics(style);
362 let height = STAR_FRAC * face_m.cap_height;
363 let outer = 0.5 * height;
364 let inner = outer * 0.38;
365 let pad = 0.12 * face_m.cap_height;
366 let advance = height + pad;
367 let cx = 0.5 * advance;
368 let cy = 0.5 * face_m.cap_height;
369 let mut ring = Vec::with_capacity(10);
370 for i in 0..10 {
371 let a = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
372 let r = if i % 2 == 0 { outer } else { inner };
373 ring.push((cx + r * a.cos(), cy + r * a.sin()));
374 }
375 let shape = Shape::from_ring(&ring);
376 (shape, advance)
377}
378
379pub fn outline_chem_run_em(glyphs: &[ChemGlyph], style: FaceStyle) -> (Option<Shape>, f64) {
387 if glyphs.is_empty() {
388 return (None, 0.0);
389 }
390 if glyphs.len() == 1 && glyphs[0].ch == '*' && glyphs[0].role == ScriptRole::Normal {
392 let (s, adv) = outline_star_em(glyphs[0].face);
393 return (if s.is_empty() { None } else { Some(s) }, adv);
394 }
395 let metrics_face = glyphs.first().map(|g| g.face).unwrap_or(style);
396 let face_m = face_metrics(metrics_face);
397 let sub_dy = -0.33 * face_m.cap_height;
398 let super_dy = 0.50 * face_m.cap_height;
399 let mut pen_x = 0.0;
400 let mut acc: Option<Shape> = None;
401 for g in glyphs {
402 let (piece, adv) = if g.ch == '*' && g.role == ScriptRole::Normal {
403 let (s, a) = outline_star_em(g.face);
404 (Some(s), a)
405 } else {
406 outline_glyph_em(g.ch, g.face, g.role)
407 };
408 let dy = match g.role {
409 ScriptRole::Normal => 0.0,
410 ScriptRole::Subscript => sub_dy,
411 ScriptRole::Superscript => super_dy,
412 };
413 if let Some(p) = piece.filter(|s| !s.is_empty()) {
414 let placed = p.translate(pen_x, dy);
415 acc = Some(match acc.take() {
416 Some(a) => a.union(&placed),
417 None => placed,
418 });
419 }
420 pen_x += adv;
421 }
422 (acc, pen_x)
423}
424
425pub fn compile_chem_shapes(
429 glyphs: &[ChemGlyph],
430 x: f64,
431 y: f64,
432 font_size: f64,
433 anchor: &str,
434 style: FaceStyle,
435) -> Option<Shape> {
436 if glyphs.is_empty() {
437 return None;
438 }
439 let face_m = face_metrics(style);
440 let scale = font_size / face_m.upem;
441 let (geom_em, advance_em) = outline_chem_run_em(glyphs, style);
442 let advance = advance_em * scale;
443 let origin_x = match anchor {
444 "end" => x - advance,
445 "start" | "left" => x,
446 _ => x - 0.5 * advance,
447 };
448 let geom_em = geom_em?;
449 let geom = geom_em
452 .scale(scale, -scale, 0.0, 0.0)
453 .translate(origin_x, y);
454 if geom.is_empty() {
455 None
456 } else {
457 Some(geom)
458 }
459}
460
461pub fn compile_text_shapes(
463 text: &str,
464 x: f64,
465 y: f64,
466 font_size: f64,
467 anchor: &str,
468 style: FaceStyle,
469) -> Option<Shape> {
470 if text.is_empty() {
471 return None;
472 }
473 let glyphs: Vec<ChemGlyph> = text
474 .chars()
475 .map(|ch| ChemGlyph {
476 ch,
477 role: ScriptRole::Normal,
478 face: style,
479 })
480 .collect();
481 compile_chem_shapes(&glyphs, x, y, font_size, anchor, style)
482}
483
484fn measure_stem_em(style: FaceStyle) -> f64 {
485 let f = face(style);
486 let upem = f64::from(f.units_per_em());
487 let Some(gid) = f.glyph_index('H') else {
488 return 0.0933;
489 };
490 let contours = outline_glyph(&f, gid);
491 if contours.is_empty() {
492 return 0.0933;
493 }
494 let geom = Shape::from_contours_evenodd(&contours);
495 let Some((_minx, miny, _maxx, maxy)) = geom.bounds() else {
496 return 0.0933;
497 };
498 let y = miny + 0.75 * (maxy - miny);
499 let span = geom.horizontal_span_at(y);
500 if span <= 0.0 {
501 0.0933
502 } else {
503 span / upem
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::metrics::{FONT_PX, FONT_STEM_EM, SCRIPT_SCALE, STAR_FRAC};
511
512 #[test]
513 fn liberation_face_metrics() {
514 let m = face_metrics(FaceStyle::Regular);
515 assert_eq!(m.upem, 2048.0);
516 assert!((m.ascent - 1854.0).abs() < 1e-6);
517 assert!((m.descent - -434.0).abs() < 1e-6);
518 assert!((m.stem_em - FONT_STEM_EM).abs() / FONT_STEM_EM < 0.05);
519 assert!(m.cap_height > 0.5 * m.upem);
520 }
521
522 #[test]
523 fn glyph_h_and_o() {
524 let h = glyph_metrics('H', FaceStyle::Regular).unwrap();
525 assert!((h.advance - 1479.0).abs() < 1e-6);
526 assert!(h.has_ink());
527 assert!(h.ink_height() > 1000.0);
528 let o = glyph_metrics('O', FaceStyle::Regular).unwrap();
529 assert!(o.ink_width() > o.advance * 0.5);
530 }
531
532 #[test]
533 fn outline_o_has_hole() {
534 let (shape, adv) = outline_run_em("O", FaceStyle::Regular);
535 let shape = shape.expect("O outline");
536 assert!(shape.has_holes());
537 assert!(adv > 0.0);
538 assert!(!shape.contains(
539 shape.centroid().unwrap().0,
540 shape.centroid().unwrap().1
541 ));
542 }
543
544 #[test]
545 fn compile_svg_space_label() {
546 let shape = compile_text_shapes("OH", 100.0, 50.0, FONT_PX, "middle", FaceStyle::Regular)
547 .expect("OH");
548 assert!(!shape.is_empty());
549 let d = shape.to_svg_d();
550 assert!(d.contains('M') && d.contains('Z'));
551 }
552
553 #[test]
554 fn script_glyph_is_smaller_but_thickened() {
555 let (full, adv_full) = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Normal);
556 let (sub, adv_sub) = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
557 let full = full.expect("2");
558 let sub = sub.expect("2 sub");
559 assert!(adv_sub < adv_full * 0.75);
560 assert!(sub.area() > 0.0);
561 assert!(sub.area() > full.area() * SCRIPT_SCALE * SCRIPT_SCALE * 0.5);
563 }
564
565 #[test]
566 fn star_is_larger_than_asterisk_advance() {
567 let (_ast, adv_ast) = outline_run_em("*", FaceStyle::Regular);
568 let (star, adv_star) = outline_star_em(FaceStyle::Regular);
569 assert!(adv_star > adv_ast * 1.2);
570 let face = face_metrics(FaceStyle::Regular);
571 let (_x0, y0, _x1, y1) = star.bounds().expect("star ink");
572 let height = y1 - y0;
573 assert!(height > 1.7 * face.cap_height);
575 assert!(height < 2.3 * face.cap_height);
576 let _ = STAR_FRAC;
577 }
578
579 #[test]
580 fn face_style_flags_and_variants() {
581 assert_eq!(FaceStyle::from_flags(false, false), FaceStyle::Regular);
582 assert_eq!(FaceStyle::from_flags(true, false), FaceStyle::Bold);
583 assert_eq!(FaceStyle::from_flags(false, true), FaceStyle::Italic);
584 assert_eq!(FaceStyle::from_flags(true, true), FaceStyle::BoldItalic);
585 for style in [
586 FaceStyle::Bold,
587 FaceStyle::Italic,
588 FaceStyle::BoldItalic,
589 ] {
590 let m = face_metrics(style);
591 assert_eq!(m.upem, 2048.0);
592 assert!(m.stem_em > 0.05);
593 }
594 }
595
596 #[test]
597 fn glyph_metrics_missing_and_empty_ink() {
598 let missing = glyph_metrics('\u{10FFFF}', FaceStyle::Regular).unwrap();
599 assert!(!missing.has_ink());
600 assert_eq!(missing.ink_width(), 0.0);
601 assert_eq!(missing.ink_height(), 0.0);
602 assert!(missing.advance > 0.0);
603
604 let (none, adv) = outline_run_em("", FaceStyle::Regular);
605 assert!(none.is_none());
606 assert_eq!(adv, 0.0);
607
608 let (shape, adv2) = outline_run_em("\u{10FFFF}", FaceStyle::Regular);
610 assert!(shape.is_none());
611 assert!(adv2 > 0.0);
612 }
613
614 #[test]
615 fn chem_run_scripts_anchors_and_empty() {
616 assert!(outline_chem_run_em(&[], FaceStyle::Regular).0.is_none());
617 assert!(compile_chem_shapes(&[], 0.0, 0.0, FONT_PX, "middle", FaceStyle::Regular).is_none());
618 assert!(compile_text_shapes("", 0.0, 0.0, FONT_PX, "middle", FaceStyle::Regular).is_none());
619
620 let glyphs = [
621 ChemGlyph {
622 ch: 'N',
623 role: ScriptRole::Normal,
624 face: FaceStyle::Regular,
625 },
626 ChemGlyph {
627 ch: '2',
628 role: ScriptRole::Subscript,
629 face: FaceStyle::Regular,
630 },
631 ChemGlyph {
632 ch: '+',
633 role: ScriptRole::Superscript,
634 face: FaceStyle::Regular,
635 },
636 ];
637 let (shape, adv) = outline_chem_run_em(&glyphs, FaceStyle::Regular);
638 assert!(shape.is_some());
639 assert!(adv > 0.0);
640
641 let start = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "start", FaceStyle::Regular)
642 .expect("start");
643 let end = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "end", FaceStyle::Regular)
644 .expect("end");
645 let mid = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "middle", FaceStyle::Regular)
646 .expect("mid");
647 assert!(!start.is_empty() && !end.is_empty() && !mid.is_empty());
648
649 let star_run = [ChemGlyph {
651 ch: '*',
652 role: ScriptRole::Normal,
653 face: FaceStyle::Regular,
654 }];
655 let (s, _) = outline_chem_run_em(&star_run, FaceStyle::Regular);
656 assert!(s.unwrap().area() > 0.0);
657 }
658
659 #[test]
660 fn script_cache_hit_and_missing_in_chem_run() {
661 let a = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
662 let b = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
663 assert_eq!(a.1, b.1);
664 assert!(a.0.is_some());
665
666 let glyphs = [
668 ChemGlyph {
669 ch: '\u{10FFFF}',
670 role: ScriptRole::Normal,
671 face: FaceStyle::Regular,
672 },
673 ChemGlyph {
674 ch: '*',
675 role: ScriptRole::Normal,
676 face: FaceStyle::Regular,
677 },
678 ];
679 let (shape, adv) = outline_chem_run_em(&glyphs, FaceStyle::Regular);
680 assert!(shape.is_some());
681 assert!(adv > 0.0);
682
683 let miss = outline_glyph_em('\u{10FFFF}', FaceStyle::Bold, ScriptRole::Normal);
685 assert!(miss.0.is_none());
686 assert!(miss.1 > 0.0);
687 }
688
689 #[test]
690 fn contour_collector_cubic_curve_to() {
691 let mut c = ContourCollector::new();
693 c.move_to(0.0, 0.0);
694 c.curve_to(10.0, 0.0, 10.0, 10.0, 0.0, 10.0);
695 c.close();
696 assert_eq!(c.contours.len(), 1);
697 assert_eq!(
698 c.contours[0].len(),
699 1 + BEZIER_STEPS,
700 "move_to + one sample per Bezier step"
701 );
702 let t = 0.5;
704 let mt = 1.0 - t;
705 let expected = (
706 mt * mt * mt * 0.0
707 + 3.0 * mt * mt * t * 10.0
708 + 3.0 * mt * t * t * 10.0
709 + t * t * t * 0.0,
710 mt * mt * mt * 0.0
711 + 3.0 * mt * mt * t * 0.0
712 + 3.0 * mt * t * t * 10.0
713 + t * t * t * 10.0,
714 );
715 let mid_idx = BEZIER_STEPS / 2;
716 let (mx, my) = c.contours[0][mid_idx];
717 assert!(
718 (mx - expected.0).abs() < 1e-9 && (my - expected.1).abs() < 1e-9,
719 "midpoint ({mx}, {my}) vs cubic@0.5 ({}, {})",
720 expected.0,
721 expected.1
722 );
723 }
724}