Skip to main content

typst_batch/codegen/
literal.rs

1//! Typst literal parsing from strings.
2//!
3//! When Typst values are serialized to JSON, some types lose their type information:
4//! - `12pt` becomes `"12pt"` (string)
5//! - `90deg` becomes `"90deg"` (string)
6//! - `50%` becomes `"50%"` (string)
7//! - `#ff0000` becomes `"#ff0000"` (string)
8//!
9//! This module provides parsers to recover these types from their string representations.
10
11use typst::foundations::Value;
12use typst::layout::{Abs, Angle, Em, Length, Ratio};
13use typst::visualize::Color;
14
15/// Try to parse a string as a Typst literal value.
16///
17/// Returns `Some(Value)` if the string matches a known literal pattern,
18/// or `None` if it should be treated as a plain string.
19///
20/// # Supported Literals
21///
22/// - Length: `12pt`, `1em`, `2cm`, `3mm`, `4in`
23/// - Angle: `90deg`, `1.5rad`, `0.25turn`
24/// - Ratio: `50%`
25/// - Color: `#rgb`, `#rrggbb`, `#rrggbbaa`
26/// - Special: `auto`, `none`, `true`, `false`
27pub fn parse_typst_literal(s: &str) -> Option<Value> {
28    let s = s.trim();
29
30    // Special values
31    match s {
32        "auto" => return Some(Value::Auto),
33        "none" => return Some(Value::None),
34        "true" => return Some(Value::Bool(true)),
35        "false" => return Some(Value::Bool(false)),
36        _ => {}
37    }
38
39    // Try each parser in order
40    if let Some(length) = parse_length(s) {
41        return Some(Value::Length(length));
42    }
43
44    if let Some(angle) = parse_angle(s) {
45        return Some(Value::Angle(angle));
46    }
47
48    if let Some(ratio) = parse_ratio(s) {
49        return Some(Value::Ratio(ratio));
50    }
51
52    if let Some(color) = parse_color(s) {
53        return Some(Value::Color(color));
54    }
55
56    None
57}
58
59/// Parse a length literal.
60///
61/// Supports:
62/// - Absolute units: `pt`, `mm`, `cm`, `in`
63/// - Relative units: `em`
64///
65/// # Examples
66/// ```ignore
67/// parse_length("12pt") // Some(Length::from(Abs::pt(12.0)))
68/// parse_length("1.5em") // Some(Length::from(Em::new(1.5)))
69/// ```
70pub fn parse_length(s: &str) -> Option<Length> {
71    let s = s.trim();
72
73    // Absolute units (pt, mm, cm, in)
74    // Conversion factors to points:
75    // - 1pt = 1pt
76    // - 1mm = 2.834645669291339pt
77    // - 1cm = 28.34645669291339pt
78    // - 1in = 72pt
79    for (suffix, factor) in [
80        ("pt", 1.0),
81        ("mm", 2.834_645_669_291_339),
82        ("cm", 28.346_456_692_913_39),
83        ("in", 72.0),
84    ] {
85        if let Some(num_str) = s.strip_suffix(suffix)
86            && let Ok(n) = num_str.trim().parse::<f64>() {
87                return Some(Abs::pt(n * factor).into());
88            }
89    }
90
91    // Relative unit: em
92    if let Some(num_str) = s.strip_suffix("em")
93        && let Ok(n) = num_str.trim().parse::<f64>() {
94            return Some(Em::new(n).into());
95        }
96
97    None
98}
99
100/// Parse an angle literal.
101///
102/// Supports: `deg`, `rad`, `turn`
103///
104/// # Examples
105/// ```ignore
106/// parse_angle("90deg") // Some(Angle::deg(90.0))
107/// parse_angle("3.14rad") // Some(Angle::rad(3.14))
108/// ```
109pub fn parse_angle(s: &str) -> Option<Angle> {
110    let s = s.trim();
111
112    if let Some(num_str) = s.strip_suffix("deg")
113        && let Ok(n) = num_str.trim().parse::<f64>() {
114            return Some(Angle::deg(n));
115        }
116
117    if let Some(num_str) = s.strip_suffix("rad")
118        && let Ok(n) = num_str.trim().parse::<f64>() {
119            return Some(Angle::rad(n));
120        }
121
122    if let Some(num_str) = s.strip_suffix("turn")
123        && let Ok(n) = num_str.trim().parse::<f64>() {
124            // 1 turn = 360 degrees
125            return Some(Angle::deg(n * 360.0));
126        }
127
128    None
129}
130
131/// Parse a ratio literal.
132///
133/// Supports: `%`
134///
135/// # Examples
136/// ```ignore
137/// parse_ratio("50%") // Some(Ratio::new(0.5))
138/// parse_ratio("100%") // Some(Ratio::new(1.0))
139/// ```
140pub fn parse_ratio(s: &str) -> Option<Ratio> {
141    let s = s.trim();
142
143    if let Some(num_str) = s.strip_suffix('%')
144        && let Ok(n) = num_str.trim().parse::<f64>() {
145            return Some(Ratio::new(n / 100.0));
146        }
147
148    None
149}
150
151/// Parse a color literal.
152///
153/// Supports hex colors:
154/// - `#rgb` (3 digits)
155/// - `#rrggbb` (6 digits)
156/// - `#rrggbbaa` (8 digits)
157///
158/// # Examples
159/// ```ignore
160/// parse_color("#f00") // Some(Color::from_u8(255, 0, 0, 255))
161/// parse_color("#ff0000") // Some(Color::from_u8(255, 0, 0, 255))
162/// ```
163pub fn parse_color(s: &str) -> Option<Color> {
164    let s = s.trim();
165
166    if !s.starts_with('#') {
167        return None;
168    }
169
170    let hex = &s[1..];
171
172    match hex.len() {
173        // #rgb → expand to #rrggbb
174        3 => {
175            let chars: Vec<char> = hex.chars().collect();
176            let r = parse_hex_digit(chars[0])? * 17;
177            let g = parse_hex_digit(chars[1])? * 17;
178            let b = parse_hex_digit(chars[2])? * 17;
179            Some(Color::from_u8(r, g, b, 255))
180        }
181        // #rrggbb
182        6 => {
183            let r = parse_hex_byte(&hex[0..2])?;
184            let g = parse_hex_byte(&hex[2..4])?;
185            let b = parse_hex_byte(&hex[4..6])?;
186            Some(Color::from_u8(r, g, b, 255))
187        }
188        // #rrggbbaa
189        8 => {
190            let r = parse_hex_byte(&hex[0..2])?;
191            let g = parse_hex_byte(&hex[2..4])?;
192            let b = parse_hex_byte(&hex[4..6])?;
193            let a = parse_hex_byte(&hex[6..8])?;
194            Some(Color::from_u8(r, g, b, a))
195        }
196        _ => None,
197    }
198}
199
200fn parse_hex_digit(c: char) -> Option<u8> {
201    match c {
202        '0'..='9' => Some(c as u8 - b'0'),
203        'a'..='f' => Some(c as u8 - b'a' + 10),
204        'A'..='F' => Some(c as u8 - b'A' + 10),
205        _ => None,
206    }
207}
208
209fn parse_hex_byte(s: &str) -> Option<u8> {
210    u8::from_str_radix(s, 16).ok()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_parse_length_pt() {
219        let length = parse_length("12pt").unwrap();
220        assert_eq!(length, Abs::pt(12.0).into());
221    }
222
223    #[test]
224    fn test_parse_length_em() {
225        let length = parse_length("1.5em").unwrap();
226        assert_eq!(length, Em::new(1.5).into());
227    }
228
229    #[test]
230    fn test_parse_length_mm() {
231        let length = parse_length("10mm").unwrap();
232        // 10mm ≈ 28.35pt
233        // Length struct has abs and em fields
234        let Length { abs, em: _ } = length;
235        assert!((abs.to_pt() - 28.346_456_692_913_39).abs() < 0.001);
236    }
237
238    #[test]
239    fn test_parse_angle_deg() {
240        let angle = parse_angle("90deg").unwrap();
241        assert_eq!(angle, Angle::deg(90.0));
242    }
243
244    #[test]
245    fn test_parse_angle_rad() {
246        let angle = parse_angle("3.14159rad").unwrap();
247        assert!((angle.to_rad() - 3.14159).abs() < 0.0001);
248    }
249
250    #[test]
251    fn test_parse_ratio() {
252        let ratio = parse_ratio("50%").unwrap();
253        assert_eq!(ratio, Ratio::new(0.5));
254    }
255
256    #[test]
257    fn test_parse_color_short() {
258        let color = parse_color("#f00").unwrap();
259        assert_eq!(color, Color::from_u8(255, 0, 0, 255));
260    }
261
262    #[test]
263    fn test_parse_color_long() {
264        let color = parse_color("#ff0000").unwrap();
265        assert_eq!(color, Color::from_u8(255, 0, 0, 255));
266    }
267
268    #[test]
269    fn test_parse_color_with_alpha() {
270        let color = parse_color("#ff000080").unwrap();
271        assert_eq!(color, Color::from_u8(255, 0, 0, 128));
272    }
273
274    #[test]
275    fn test_parse_special_values() {
276        assert_eq!(parse_typst_literal("auto"), Some(Value::Auto));
277        assert_eq!(parse_typst_literal("none"), Some(Value::None));
278        assert_eq!(parse_typst_literal("true"), Some(Value::Bool(true)));
279        assert_eq!(parse_typst_literal("false"), Some(Value::Bool(false)));
280    }
281
282    #[test]
283    fn test_plain_string() {
284        // These should NOT be parsed as literals
285        assert!(parse_typst_literal("hello").is_none());
286        assert!(parse_typst_literal("12").is_none()); // No unit
287        assert!(parse_typst_literal("just some text").is_none());
288    }
289}