1use super::BrushStroke;
4#[cfg(test)]
5use motion_core::Color;
6use motion_core::{Paint, Path2D, Point, ValidationError};
7
8#[derive(Clone, Copy, Debug)]
10pub struct StrokeFont {
11 height: f64,
12}
13
14impl StrokeFont {
15 pub fn new(height: f64) -> Result<Self, ValidationError> {
17 if !height.is_finite() || !(f64::MIN_POSITIVE..=512.0).contains(&height) {
18 return Err(invalid("height", "Require positive finite height <=512"));
19 }
20 Ok(Self { height })
21 }
22
23 pub fn strokes(
26 &self,
27 text: &str,
28 width: f64,
29 paint: impl Into<Paint>,
30 ) -> Result<Vec<BrushStroke>, ValidationError> {
31 if text.chars().count() > 256
32 || !width.is_finite()
33 || !(f64::MIN_POSITIVE..=512.0).contains(&width)
34 {
35 return Err(invalid(
36 "text",
37 "Require <=256 characters and positive finite width <=512",
38 ));
39 }
40 let paint = paint.into();
41 let segments = [
42 [[0.0, 0.0], [1.0, 0.0]],
43 [[1.0, 0.0], [1.0, 0.5]],
44 [[1.0, 0.5], [1.0, 1.0]],
45 [[0.0, 1.0], [1.0, 1.0]],
46 [[0.0, 0.5], [0.0, 1.0]],
47 [[0.0, 0.0], [0.0, 0.5]],
48 [[0.0, 0.5], [0.5, 0.5]],
49 [[0.5, 0.5], [1.0, 0.5]],
50 [[0.0, 0.0], [0.5, 0.5]],
51 [[1.0, 0.0], [0.5, 0.5]],
52 [[0.5, 0.5], [0.0, 1.0]],
53 [[0.5, 0.5], [1.0, 1.0]],
54 [[0.5, 0.0], [0.5, 0.5]],
55 [[0.5, 0.5], [0.5, 1.0]],
56 ];
57 let mut result = Vec::new();
58 let mut column = 0.0;
59 let mut row = 0.0;
60 for character in text.chars() {
61 if character == '\n' {
62 column = 0.0;
63 row += 1.4;
64 continue;
65 }
66 if character == ' ' {
67 column += 1.0;
68 continue;
69 }
70 let glyph = glyph(character.to_ascii_uppercase())
71 .ok_or_else(|| invalid("glyph", "Unsupported single-line character"))?;
72 let mut path = Path2D::new();
73 for index in glyph {
74 let [first, second] = segments[*index];
75 let point = |value: [f64; 2]| {
76 Point::new(
77 (column + value[0] * 0.65) * self.height,
78 (row + value[1]) * self.height,
79 )
80 };
81 path.try_move_to(point(first))?;
82 path.try_line_to(point(second))?;
83 }
84 result.push(BrushStroke::new(path, width, paint.clone()));
85 column += 1.0;
86 }
87 Ok(result)
88 }
89}
90
91fn invalid(field: &str, message: &str) -> ValidationError {
92 ValidationError::invalid("stroke_font", field, message)
93}
94
95fn glyph(character: char) -> Option<&'static [usize]> {
96 Some(match character {
97 'A' => &[0, 1, 2, 4, 5, 6, 7],
98 'B' | '8' => &[0, 1, 2, 3, 4, 5, 6, 7],
99 'C' => &[0, 3, 4, 5],
100 'D' | 'O' | '0' => &[0, 1, 2, 3, 4, 5],
101 'E' => &[0, 3, 4, 5, 6, 7],
102 'F' => &[0, 4, 5, 6, 7],
103 'G' => &[0, 2, 3, 4, 5, 7],
104 'H' => &[1, 2, 4, 5, 6, 7],
105 'I' => &[0, 3, 12, 13],
106 'J' => &[1, 2, 3, 4],
107 'K' => &[4, 5, 9, 11],
108 'L' => &[3, 4, 5],
109 'M' => &[1, 2, 4, 5, 8, 9],
110 'N' => &[1, 2, 4, 5, 8, 11],
111 'P' => &[0, 1, 4, 5, 6, 7],
112 'Q' => &[0, 1, 2, 3, 4, 5, 11],
113 'R' => &[0, 1, 4, 5, 6, 7, 11],
114 'S' | '5' => &[0, 2, 3, 5, 6, 7],
115 'T' => &[0, 12, 13],
116 'U' => &[1, 2, 3, 4, 5],
117 'V' => &[1, 5, 10, 11],
118 'W' => &[1, 2, 4, 5, 10, 11],
119 'X' => &[8, 9, 10, 11],
120 'Y' => &[8, 9, 13],
121 'Z' => &[0, 3, 9, 10],
122 '1' => &[1, 2],
123 '2' => &[0, 1, 3, 4, 6, 7],
124 '3' => &[0, 1, 2, 3, 6, 7],
125 '4' => &[1, 2, 5, 6, 7],
126 '6' => &[0, 2, 3, 4, 5, 6, 7],
127 '7' => &[0, 1, 2],
128 '9' => &[0, 1, 2, 3, 5, 6, 7],
129 '-' => &[6, 7],
130 '+' => &[6, 7, 12, 13],
131 _ => return None,
132 })
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn lettering_is_bounded_deterministic_and_asset_free() -> motion_core::Result {
141 let font = StrokeFont::new(20.0)?;
142 let text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-";
143 let first = font.strokes(text, 1.0, Color::WHITE)?;
144 let second = font.strokes(text, 1.0, Color::WHITE)?;
145 assert_eq!(first.len(), 38);
146 let nodes = first
147 .into_iter()
148 .map(motion_core::Node::from)
149 .collect::<Vec<_>>();
150 let again = second
151 .into_iter()
152 .map(motion_core::Node::from)
153 .collect::<Vec<_>>();
154 let compile = |nodes| {
155 motion_core::Node::group(nodes).compile(
156 motion_core::Size::new(800.0, 40.0),
157 motion_core::PixelSize::new(800, 40),
158 motion_core::ValidationLimits::default(),
159 )
160 };
161 assert_eq!(compile(nodes)?, compile(again)?);
162 assert!(font.strokes("\u{4e2d}", 1.0, Color::WHITE).is_err());
163 assert!(font.strokes(&"A".repeat(257), 1.0, Color::WHITE).is_err());
164 assert!(StrokeFont::new(f64::NAN).is_err());
165 Ok(())
166 }
167}