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