Skip to main content

typst_batch/codegen/
inputs.rs

1//! Builder for `sys.inputs` from JSON.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use serde_json::Value as JsonValue;
7use typst::comemo::Track;
8use typst::engine::{Engine, Route, Sink, Traced};
9use typst::foundations::{Array, Context, Dict, IntoValue, Value};
10use typst::introspection::Introspector;
11use typst::World;
12
13use super::{json_to_content, ConvertError};
14use crate::world::TypstWorld;
15
16/// Opaque inputs type for `sys.inputs` injection.
17///
18/// Created via [`Inputs::from_json()`]. Pass to [`Batcher::with_inputs()`].
19pub struct Inputs {
20    pub(crate) dict: Dict,
21}
22
23impl Inputs {
24    /// Create empty inputs.
25    pub fn empty() -> Self {
26        Self { dict: Dict::new() }
27    }
28
29    /// Create inputs from JSON value.
30    ///
31    /// Automatically maps JSON types to Typst types:
32    /// - `string` → `Str`
33    /// - `number` (integer) → `Int`
34    /// - `number` (float) → `Float`
35    /// - `boolean` → `Bool`
36    /// - `array` → `Array`
37    /// - `object` → `Dict`
38    /// - `null` → `None`
39    ///
40    /// # Example
41    ///
42    /// ```ignore
43    /// let base_json = serde_json::json!({
44    ///     "title": "My Blog",
45    ///     "description": "A personal blog",
46    ///     "extra": {
47    ///         "author": "Alice",
48    ///         "twitter": "@alice"
49    ///     }
50    /// });
51    /// let inputs = Inputs::from_json(&base_json)?;
52    /// ```
53    pub fn from_json(json: &JsonValue) -> Result<Self, ConvertError> {
54        match json {
55            JsonValue::Object(_) => {
56                let dict = json_to_simple_dict(json)?;
57                Ok(Self { dict })
58            }
59            _ => Err(ConvertError::Other("inputs must be a JSON object".into())),
60        }
61    }
62
63    /// Create inputs from JSON with Content reconstruction.
64    ///
65    /// Like [`from_json()`](Self::from_json), but automatically rebuilds
66    /// Typst Content from JSON objects containing `{"func": ...}`.
67    ///
68    /// Use this when your JSON contains serialized Typst Content (e.g.,
69    /// metadata extracted from compiled pages).
70    ///
71    /// # Example
72    ///
73    /// ```ignore
74    /// // JSON with Content field (has "func")
75    /// let pages_json = serde_json::json!({
76    ///     "pages": [
77    ///         {
78    ///             "url": "/post/1",
79    ///             "summary": {
80    ///                 "func": "sequence",
81    ///                 "children": [
82    ///                     {"func": "text", "text": "Hello "},
83    ///                     {"func": "link", "dest": "https://example.com", "body": {...}}
84    ///                 ]
85    ///             }
86    ///         }
87    ///     ]
88    /// });
89    ///
90    /// // Content fields are automatically rebuilt
91    /// let inputs = Inputs::from_json_with_content(&pages_json, root)?;
92    /// ```
93    pub fn from_json_with_content(json: &JsonValue, root: &Path) -> Result<Self, ConvertError> {
94        match json {
95            JsonValue::Object(_) => {
96                let converter = ContentConverter::new(root);
97                let dict = converter.convert_dict(json)?;
98                Ok(Self { dict })
99            }
100            _ => Err(ConvertError::Other("inputs must be a JSON object".into())),
101        }
102    }
103
104    /// Merge another Inputs into this one.
105    ///
106    /// Values from `other` overwrite values in `self` for duplicate keys.
107    pub fn merge(&mut self, other: Inputs) {
108        for (key, value) in other.dict {
109            self.dict.insert(key, value);
110        }
111    }
112
113    /// Merge a JSON object into this Inputs.
114    pub fn merge_json(&mut self, json: &JsonValue) -> Result<(), ConvertError> {
115        let other = Self::from_json(json)?;
116        self.merge(other);
117        Ok(())
118    }
119
120    /// Get the underlying Dict.
121    pub fn into_dict(self) -> Dict {
122        self.dict
123    }
124}
125
126/// Convert JSON value to Typst Value (simple, without Content reconstruction).
127///
128/// This is a lightweight conversion that maps JSON types directly to Typst types.
129/// Objects with `{"func": ...}` are converted to Dict, not Content.
130///
131/// For Content reconstruction, use [`Inputs::from_json_with_content()`].
132pub fn json_to_simple_value(json: &JsonValue) -> Result<Value, ConvertError> {
133    match json {
134        JsonValue::Null => Ok(Value::None),
135        JsonValue::Bool(b) => Ok(b.into_value()),
136        JsonValue::Number(n) => {
137            if let Some(i) = n.as_i64() {
138                Ok(i.into_value())
139            } else if let Some(f) = n.as_f64() {
140                Ok(f.into_value())
141            } else {
142                Err(ConvertError::Other(format!("unsupported number: {n}")))
143            }
144        }
145        JsonValue::String(s) => Ok(s.as_str().into_value()),
146        JsonValue::Array(arr) => {
147            let items: Result<Vec<_>, _> = arr.iter().map(json_to_simple_value).collect();
148            let array: typst::foundations::Array = items?.into_iter().collect();
149            Ok(array.into_value())
150        }
151        JsonValue::Object(_) => {
152            let dict = json_to_simple_dict(json)?;
153            Ok(dict.into_value())
154        }
155    }
156}
157
158/// Convert JSON object to Typst Dict (simple, without Content reconstruction).
159fn json_to_simple_dict(json: &JsonValue) -> Result<Dict, ConvertError> {
160    let obj = json
161        .as_object()
162        .ok_or_else(|| ConvertError::Other("expected JSON object".into()))?;
163
164    let mut dict = Dict::new();
165    for (key, value) in obj {
166        dict.insert(key.as_str().into(), json_to_simple_value(value)?);
167    }
168    Ok(dict)
169}
170
171// =============================================================================
172// ContentConverter - JSON to Value with Content reconstruction
173// =============================================================================
174
175/// Converter that rebuilds Content from JSON.
176struct ContentConverter {
177    world: Arc<TypstWorld>,
178}
179
180impl ContentConverter {
181    fn new(root: &Path) -> Self {
182        // Create a minimal World for Content reconstruction
183        // We use a dummy path since we don't actually compile anything
184        let dummy_path = root.join("__content_converter_dummy.typ");
185        let world = TypstWorld::builder(&dummy_path, root)
186            .with_local_cache()
187            .no_fonts()
188            .build();
189
190        Self {
191            world: Arc::new(world),
192        }
193    }
194
195    fn convert_value(&self, json: &JsonValue) -> Result<Value, ConvertError> {
196        match json {
197            JsonValue::Null => Ok(Value::None),
198            JsonValue::Bool(b) => Ok(b.into_value()),
199            JsonValue::Number(n) => {
200                if let Some(i) = n.as_i64() {
201                    Ok(i.into_value())
202                } else if let Some(f) = n.as_f64() {
203                    Ok(f.into_value())
204                } else {
205                    Err(ConvertError::Other(format!("unsupported number: {n}")))
206                }
207            }
208            JsonValue::String(s) => Ok(s.as_str().into_value()),
209            JsonValue::Array(arr) => {
210                let mut result = Array::new();
211                for item in arr {
212                    result.push(self.convert_value(item)?);
213                }
214                Ok(result.into_value())
215            }
216            JsonValue::Object(obj) => {
217                // Check if this is Typst Content (has "func" field)
218                if obj.contains_key("func") {
219                    self.rebuild_content(json)
220                } else {
221                    Ok(self.convert_dict(json)?.into_value())
222                }
223            }
224        }
225    }
226
227    fn convert_dict(&self, json: &JsonValue) -> Result<Dict, ConvertError> {
228        let obj = json
229            .as_object()
230            .ok_or_else(|| ConvertError::Other("expected JSON object".into()))?;
231
232        let mut dict = Dict::new();
233        for (key, value) in obj {
234            dict.insert(key.as_str().into(), self.convert_value(value)?);
235        }
236        Ok(dict)
237    }
238
239    fn rebuild_content(&self, json: &JsonValue) -> Result<Value, ConvertError> {
240        let introspector = Introspector::default();
241        let traced = Traced::default();
242        let mut sink = Sink::new();
243
244        let mut engine = Engine {
245            world: (&*self.world as &dyn World).track(),
246            introspector: introspector.track(),
247            traced: traced.track(),
248            sink: sink.track_mut(),
249            route: Route::default(),
250            routines: &typst::ROUTINES,
251        };
252
253        let library = self.world.library();
254        let context = Context::none();
255
256        let content = json_to_content(&mut engine, context.track(), library, json)?;
257        Ok(content.into_value())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265    use tempfile::TempDir;
266    use typst::foundations::Str;
267
268    #[test]
269    fn test_from_json_simple() {
270        let json = json!({
271            "title": "My Blog",
272            "count": 42,
273            "ratio": 3.14,
274            "draft": false
275        });
276        let inputs = Inputs::from_json(&json).unwrap();
277        assert_eq!(inputs.dict.len(), 4);
278    }
279
280    #[test]
281    fn test_from_json_nested() {
282        let json = json!({
283            "title": "My Blog",
284            "extra": {
285                "author": "Alice",
286                "twitter": "@alice"
287            }
288        });
289        let inputs = Inputs::from_json(&json).unwrap();
290        assert_eq!(inputs.dict.len(), 2);
291
292        // Check nested dict
293        let extra = inputs.dict.get(&Str::from("extra")).unwrap();
294        assert!(extra.clone().cast::<Dict>().is_ok());
295    }
296
297    #[test]
298    fn test_from_json_array() {
299        let json = json!({
300            "tags": ["rust", "typst", "blog"]
301        });
302        let inputs = Inputs::from_json(&json).unwrap();
303
304        let tags = inputs.dict.get(&Str::from("tags")).unwrap();
305        let arr = tags.clone().cast::<Array>().unwrap();
306        assert_eq!(arr.len(), 3);
307    }
308
309    #[test]
310    fn test_from_json_null() {
311        let json = json!({
312            "value": null
313        });
314        let inputs = Inputs::from_json(&json).unwrap();
315
316        let value = inputs.dict.get(&Str::from("value")).unwrap();
317        assert_eq!(*value, Value::None);
318    }
319
320    #[test]
321    fn test_from_json_not_object() {
322        let json = json!("not an object");
323        assert!(Inputs::from_json(&json).is_err());
324    }
325
326    #[test]
327    fn test_from_json_with_content_simple() {
328        let dir = TempDir::new().unwrap();
329
330        // JSON without Content fields - should work like from_json
331        let json = json!({
332            "title": "My Blog",
333            "count": 42
334        });
335
336        let inputs = Inputs::from_json_with_content(&json, dir.path()).unwrap();
337        assert_eq!(inputs.dict.len(), 2);
338    }
339
340    #[test]
341    fn test_from_json_with_content_rebuilds_content() {
342        let dir = TempDir::new().unwrap();
343
344        // JSON with Content field (has "func")
345        let json = json!({
346            "pages": [
347                {
348                    "url": "/post/1",
349                    "title": "First Post",
350                    "summary": {
351                        "func": "text",
352                        "text": "Hello world"
353                    }
354                }
355            ]
356        });
357
358        let inputs = Inputs::from_json_with_content(&json, dir.path()).unwrap();
359
360        // Get pages array
361        let pages = inputs.dict.get(&Str::from("pages")).unwrap();
362        let pages_arr = pages.clone().cast::<Array>().unwrap();
363        assert_eq!(pages_arr.len(), 1);
364
365        // Get first page
366        let page = pages_arr.at(0, None).unwrap();
367        let page_dict = page.clone().cast::<Dict>().unwrap();
368
369        // summary should be Content, not Dict
370        let summary = page_dict.get(&Str::from("summary")).unwrap();
371        assert!(
372            summary.clone().cast::<Dict>().is_err(),
373            "summary should be Content, not Dict"
374        );
375    }
376
377    #[test]
378    fn test_from_json_with_content_complex() {
379        let dir = TempDir::new().unwrap();
380
381        // Complex Content with link
382        let json = json!({
383            "summary": {
384                "func": "sequence",
385                "children": [
386                    {"func": "text", "text": "Check out "},
387                    {
388                        "func": "link",
389                        "dest": "https://example.com",
390                        "body": {"func": "text", "text": "this link"}
391                    },
392                    {"func": "text", "text": "!"}
393                ]
394            }
395        });
396
397        let inputs = Inputs::from_json_with_content(&json, dir.path()).unwrap();
398
399        let summary = inputs.dict.get(&Str::from("summary")).unwrap();
400        // Should be Content, not Dict
401        assert!(summary.clone().cast::<Dict>().is_err());
402    }
403}