Skip to main content

motion_content/vector/ink/
lettering.rs

1//! Original geometric fourteen-segment single-line glyphs; no bundled font assets.
2
3use super::BrushStroke;
4#[cfg(test)]
5use motion_core::Color;
6use motion_core::{Paint, Path2D, Point, ValidationError};
7
8/// Bounded single-line Latin lettering built from original segment geometry.
9#[derive(Clone, Copy, Debug)]
10pub struct StrokeFont {
11    height: f64,
12}
13
14impl StrokeFont {
15    /// Creates uppercase/digit lettering with positive local height, at most 512 units.
16    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    /// Emits one extended stroke per glyph; spaces/newlines advance without drawing.
24    /// Lowercase is mapped to uppercase; unsupported characters fail instead of substituting.
25    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}