Skip to main content

quillmark_typst/
lib.rs

1//! # Typst Backend for Quillmark
2//!
3//! This crate provides a complete Typst backend implementation that converts Markdown
4//! documents to PDF and SVG formats via the Typst typesetting system.
5//!
6//! ## Overview
7//!
8//! The primary entry point is the [`TypstBackend`] struct, which implements the
9//! [`Backend`] trait from `quillmark-core`. Users typically interact with this backend
10//! through the high-level `Quill` API from the `quillmark` crate.
11//!
12//! ## Features
13//!
14//! - Converts CommonMark Markdown to Typst markup
15//! - Compiles Typst documents to PDF and SVG formats
16//! - Provides template filters for YAML data transformation
17//! - Manages fonts, assets, and packages dynamically
18//! - Thread-safe for concurrent rendering
19//!
20//! ## Modules
21//!
22//! - [`convert`] - Markdown to Typst conversion utilities
23//! - [`compile`] - Typst to PDF/SVG compilation functions
24//!
25//! Note: The `error_mapping` module provides internal utilities for converting Typst
26//! diagnostics to Quillmark diagnostics and is not part of the public API.
27
28pub mod compile;
29pub mod convert;
30mod error_mapping;
31
32pub mod helper;
33mod world;
34
35/// Utilities exposed for fuzzing tests.
36/// Not intended for general use.
37#[doc(hidden)]
38pub mod fuzz_utils {
39    pub use super::helper::inject_json;
40}
41
42use convert::mark_to_typst;
43use quillmark_core::{
44    quill::build_transform_schema, session::SessionHandle, Backend, Diagnostic, OutputFormat,
45    QuillSource, QuillValue, RenderError, RenderOptions, RenderResult, RenderSession, Severity,
46};
47use std::any::Any;
48use std::collections::HashMap;
49
50/// Typst backend implementation for Quillmark.
51#[derive(Debug)]
52pub struct TypstBackend;
53
54const SUPPORTED_FORMATS: &[OutputFormat] =
55    &[OutputFormat::Pdf, OutputFormat::Svg, OutputFormat::Png];
56
57/// Typst-specific render session.
58///
59/// Holds the cached `PagedDocument` produced by [`Backend::open`] and exposes
60/// Typst-only operations (page geometry, raster rendering) used by the WASM
61/// canvas painter. Reach this from a [`RenderSession`] via
62/// [`typst_session_of`].
63#[derive(Debug)]
64pub struct TypstSession {
65    document: typst::layout::PagedDocument,
66    page_count: usize,
67}
68
69impl TypstSession {
70    /// Page dimensions in Typst points (1 pt = 1/72 inch).
71    ///
72    /// Returns `None` if `page` is out of range.
73    pub fn page_size_pt(&self, page: usize) -> Option<(f32, f32)> {
74        let frame = &self.document.pages.get(page)?.frame;
75        let size = frame.size();
76        Some((size.x.to_pt() as f32, size.y.to_pt() as f32))
77    }
78
79    /// Render `page` to a non-premultiplied RGBA8 buffer at `scale`× the
80    /// natural 72 ppi (i.e. `scale = 1` → 1 device pixel per Typst pt).
81    ///
82    /// Returns `(width_px, height_px, rgba)`. The buffer is `width_px *
83    /// height_px * 4` bytes, row-major, ready to hand to `ImageData` or any
84    /// other RGBA consumer. Returns `None` if `page` is out of range.
85    pub fn render_rgba(&self, page: usize, scale: f32) -> Option<(u32, u32, Vec<u8>)> {
86        let p = self.document.pages.get(page)?;
87        let pixmap = typst_render::render(p, scale);
88        let width = pixmap.width();
89        let height = pixmap.height();
90        let mut rgba = Vec::with_capacity((width as usize) * (height as usize) * 4);
91        for px in pixmap.pixels() {
92            let c = px.demultiply();
93            rgba.push(c.red());
94            rgba.push(c.green());
95            rgba.push(c.blue());
96            rgba.push(c.alpha());
97        }
98        Some((width, height, rgba))
99    }
100}
101
102impl SessionHandle for TypstSession {
103    fn render(&self, opts: &RenderOptions) -> Result<RenderResult, RenderError> {
104        let format = opts.output_format.unwrap_or(OutputFormat::Pdf);
105
106        if !SUPPORTED_FORMATS.contains(&format) {
107            return Err(RenderError::FormatNotSupported {
108                diag: Box::new(
109                    Diagnostic::new(
110                        Severity::Error,
111                        format!("{:?} not supported by typst backend", format),
112                    )
113                    .with_code("backend::format_not_supported".to_string())
114                    .with_hint(format!("Supported formats: {:?}", SUPPORTED_FORMATS)),
115                ),
116            });
117        }
118
119        compile::render_document_pages(&self.document, opts.pages.as_deref(), format, opts.ppi)
120    }
121
122    fn page_count(&self) -> usize {
123        self.page_count
124    }
125
126    fn as_any(&self) -> &dyn Any {
127        self
128    }
129}
130
131/// Borrow the [`TypstSession`] underlying a [`RenderSession`], if the session
132/// was opened by the Typst backend.
133///
134/// Returns `None` for any other backend. Bindings that need Typst-only
135/// capabilities (canvas paint, page geometry) call this to access them
136/// without forcing core to know about backend specifics.
137pub fn typst_session_of(session: &RenderSession) -> Option<&TypstSession> {
138    session.handle().as_any().downcast_ref::<TypstSession>()
139}
140
141impl Backend for TypstBackend {
142    fn id(&self) -> &'static str {
143        "typst"
144    }
145
146    fn supported_formats(&self) -> &'static [OutputFormat] {
147        SUPPORTED_FORMATS
148    }
149
150    fn open(
151        &self,
152        plate_content: &str,
153        source: &QuillSource,
154        json_data: &serde_json::Value,
155    ) -> Result<RenderSession, RenderError> {
156        let fields = json_data.as_object().map_or_else(HashMap::new, |obj| {
157            obj.iter()
158                .map(|(key, value)| (key.clone(), QuillValue::from_json(value.clone())))
159                .collect::<HashMap<_, _>>()
160        });
161
162        let transformed_fields =
163            transform_markdown_fields(&fields, &build_transform_schema(source.config()));
164        let transformed_json = serde_json::Value::Object(
165            transformed_fields
166                .into_iter()
167                .map(|(key, value)| (key, value.into_json()))
168                .collect(),
169        );
170
171        let json_str =
172            serde_json::to_string(&transformed_json).unwrap_or_else(|_| "{}".to_string());
173        let document = compile::compile_to_document(source, plate_content, &json_str)?;
174        let page_count = document.pages.len();
175        let session = TypstSession {
176            document,
177            page_count,
178        };
179        Ok(RenderSession::new(Box::new(session)))
180    }
181}
182
183impl Default for TypstBackend {
184    /// Creates a new [`TypstBackend`] instance.
185    fn default() -> Self {
186        Self
187    }
188}
189
190/// Check if a field schema indicates markdown content.
191///
192/// A field is considered markdown if it has:
193/// - `contentMediaType = "text/markdown"`
194fn is_markdown_field(field_schema: &serde_json::Value) -> bool {
195    field_schema
196        .get("contentMediaType")
197        .and_then(|v| v.as_str())
198        .map(|s| s == "text/markdown")
199        .unwrap_or(false)
200}
201
202/// Check if a field schema indicates a date field.
203///
204/// A field is considered a date if it has:
205/// - `type = "string"`
206/// - `format = "date"`
207fn is_date_field(field_schema: &serde_json::Value) -> bool {
208    let is_string = field_schema
209        .get("type")
210        .and_then(|v| v.as_str())
211        .map(|s| s == "string")
212        .unwrap_or(false);
213
214    let is_date_format = field_schema
215        .get("format")
216        .and_then(|v| v.as_str())
217        .map(|s| s == "date")
218        .unwrap_or(false);
219
220    is_string && is_date_format
221}
222
223/// Transform markdown fields to Typst markup based on schema.
224///
225/// Identifies fields with `contentMediaType = "text/markdown"` and converts
226/// their content using `mark_to_typst()`. This includes recursive handling
227/// of CARDS arrays.
228///
229/// Also injects a `__meta__` key into the result containing the names of
230/// converted fields, which the quillmark-helper package uses to auto-evaluate
231/// markup strings into Typst content objects.
232fn transform_markdown_fields(
233    fields: &HashMap<String, QuillValue>,
234    schema: &QuillValue,
235) -> HashMap<String, QuillValue> {
236    let mut result = fields.clone();
237    let schema_json = schema.as_json();
238
239    // Get the properties object from the schema
240    let properties_obj = match schema_json.get("properties").and_then(|v| v.as_object()) {
241        Some(obj) => obj,
242        None => return result,
243    };
244
245    // Transform each field based on schema, collecting converted field names
246    let mut content_field_names: Vec<&str> = Vec::new();
247    for (field_name, field_value) in fields {
248        if let Some(field_schema) = properties_obj.get(field_name) {
249            if is_markdown_field(field_schema) {
250                if let Some(content) = field_value.as_str() {
251                    if let Ok(typst_markup) = mark_to_typst(content) {
252                        result.insert(
253                            field_name.clone(),
254                            QuillValue::from_json(serde_json::json!(typst_markup)),
255                        );
256                        content_field_names.push(field_name);
257                    }
258                }
259            }
260        }
261    }
262
263    let date_fields: Vec<&str> = properties_obj
264        .iter()
265        .filter(|(_, fs)| is_date_field(fs))
266        .map(|(name, _)| name.as_str())
267        .collect();
268
269    // Handle CARDS array recursively
270    if let Some(cards_value) = result.get("CARDS") {
271        if let Some(cards_array) = cards_value.as_array() {
272            let transformed_cards = transform_cards_array(schema, cards_array);
273            result.insert(
274                "CARDS".to_string(),
275                QuillValue::from_json(serde_json::Value::Array(transformed_cards)),
276            );
277        }
278    }
279
280    // Collect per-card-type content field names from schema $defs
281    let mut card_content_fields = serde_json::Map::new();
282    let mut card_date_fields = serde_json::Map::new();
283    if let Some(defs) = schema_json.get("$defs").and_then(|v| v.as_object()) {
284        for (def_name, def_schema) in defs {
285            if let Some(card_type) = def_name.strip_suffix("_card") {
286                let card_fields: Vec<&str> = def_schema
287                    .get("properties")
288                    .and_then(|v| v.as_object())
289                    .map(|props| {
290                        props
291                            .iter()
292                            .filter(|(_, fs)| is_markdown_field(fs))
293                            .map(|(name, _)| name.as_str())
294                            .collect()
295                    })
296                    .unwrap_or_default();
297                if !card_fields.is_empty() {
298                    card_content_fields.insert(
299                        card_type.to_string(),
300                        serde_json::Value::Array(
301                            card_fields
302                                .into_iter()
303                                .map(|s| serde_json::Value::String(s.to_string()))
304                                .collect(),
305                        ),
306                    );
307                }
308
309                let date_fields: Vec<&str> = def_schema
310                    .get("properties")
311                    .and_then(|v| v.as_object())
312                    .map(|props| {
313                        props
314                            .iter()
315                            .filter(|(_, fs)| is_date_field(fs))
316                            .map(|(name, _)| name.as_str())
317                            .collect()
318                    })
319                    .unwrap_or_default();
320                if !date_fields.is_empty() {
321                    card_date_fields.insert(
322                        card_type.to_string(),
323                        serde_json::Value::Array(
324                            date_fields
325                                .into_iter()
326                                .map(|s| serde_json::Value::String(s.to_string()))
327                                .collect(),
328                        ),
329                    );
330                }
331            }
332        }
333    }
334
335    // Inject __meta__ so the helper package can auto-eval content fields
336    result.insert(
337        "__meta__".to_string(),
338        QuillValue::from_json(serde_json::json!({
339            "content_fields": content_field_names,
340            "card_content_fields": card_content_fields,
341            "date_fields": date_fields,
342            "card_date_fields": card_date_fields,
343        })),
344    );
345
346    result
347}
348
349/// Transform markdown fields in CARDS array items.
350fn transform_cards_array(
351    document_schema: &QuillValue,
352    cards_array: &[serde_json::Value],
353) -> Vec<serde_json::Value> {
354    let mut transformed_cards = Vec::new();
355
356    // Get definitions for card schemas
357    let defs = document_schema
358        .as_json()
359        .get("$defs")
360        .and_then(|v| v.as_object());
361
362    for card in cards_array {
363        if let Some(card_obj) = card.as_object() {
364            if let Some(card_type) = card_obj.get("CARD").and_then(|v| v.as_str()) {
365                // Construct the definition name: {type}_card
366                let def_name = format!("{}_card", card_type);
367
368                // Look up the schema for this card type
369                if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
370                    // Convert the card object to HashMap<String, QuillValue>
371                    let mut card_fields: HashMap<String, QuillValue> = HashMap::new();
372                    for (k, v) in card_obj {
373                        card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
374                    }
375
376                    // Recursively transform this card's fields
377                    let transformed_card_fields = transform_markdown_fields(
378                        &card_fields,
379                        &QuillValue::from_json(card_schema_json.clone()),
380                    );
381
382                    // Convert back to JSON Value
383                    let mut transformed_card_obj = serde_json::Map::new();
384                    for (k, v) in transformed_card_fields {
385                        transformed_card_obj.insert(k, v.into_json());
386                    }
387
388                    transformed_cards.push(serde_json::Value::Object(transformed_card_obj));
389                    continue;
390                }
391            }
392        }
393
394        // If not an object, no CARD type, or no matching schema, keep as-is
395        transformed_cards.push(card.clone());
396    }
397
398    transformed_cards
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde_json::json;
405
406    #[test]
407    fn test_backend_info() {
408        let backend = TypstBackend;
409        assert_eq!(backend.id(), "typst");
410        assert!(backend.supported_formats().contains(&OutputFormat::Pdf));
411        assert!(backend.supported_formats().contains(&OutputFormat::Svg));
412    }
413
414    #[test]
415    fn test_is_markdown_field() {
416        let markdown_schema = json!({
417            "type": "string",
418            "contentMediaType": "text/markdown"
419        });
420        assert!(is_markdown_field(&markdown_schema));
421
422        let string_schema = json!({
423            "type": "string"
424        });
425        assert!(!is_markdown_field(&string_schema));
426
427        let other_media_type = json!({
428            "type": "string",
429            "contentMediaType": "text/plain"
430        });
431        assert!(!is_markdown_field(&other_media_type));
432    }
433
434    #[test]
435    fn test_is_date_field() {
436        let date_schema = json!({
437            "type": "string",
438            "format": "date"
439        });
440        assert!(is_date_field(&date_schema));
441
442        let date_time_schema = json!({
443            "type": "string",
444            "format": "date-time"
445        });
446        assert!(!is_date_field(&date_time_schema));
447
448        let non_string_date_schema = json!({
449            "type": "number",
450            "format": "date"
451        });
452        assert!(!is_date_field(&non_string_date_schema));
453    }
454
455    #[test]
456    fn test_transform_markdown_fields_basic() {
457        let schema = QuillValue::from_json(json!({
458            "type": "object",
459            "properties": {
460                "title": { "type": "string" },
461                "BODY": { "type": "string", "contentMediaType": "text/markdown" }
462            }
463        }));
464
465        let mut fields = HashMap::new();
466        fields.insert(
467            "title".to_string(),
468            QuillValue::from_json(json!("My Title")),
469        );
470        fields.insert(
471            "BODY".to_string(),
472            QuillValue::from_json(json!("This is **bold** text.")),
473        );
474
475        let result = transform_markdown_fields(&fields, &schema);
476
477        // title should be unchanged
478        assert_eq!(result.get("title").unwrap().as_str(), Some("My Title"));
479
480        // BODY should be converted to Typst markup
481        let body = result.get("BODY").unwrap().as_str().unwrap();
482        assert!(body.contains("#strong[bold]"));
483    }
484
485    #[test]
486    fn test_transform_markdown_fields_no_markdown() {
487        let schema = QuillValue::from_json(json!({
488            "type": "object",
489            "properties": {
490                "title": { "type": "string" },
491                "count": { "type": "number" }
492            }
493        }));
494
495        let mut fields = HashMap::new();
496        fields.insert(
497            "title".to_string(),
498            QuillValue::from_json(json!("My Title")),
499        );
500        fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
501
502        let result = transform_markdown_fields(&fields, &schema);
503
504        // All fields should be unchanged
505        assert_eq!(result.get("title").unwrap().as_str(), Some("My Title"));
506        assert_eq!(result.get("count").unwrap().as_i64(), Some(42));
507    }
508
509    #[test]
510    fn test_transform_markdown_fields_wrapper() {
511        let schema = QuillValue::from_json(json!({
512            "type": "object",
513            "properties": {
514                "BODY": { "type": "string", "contentMediaType": "text/markdown" }
515            }
516        }));
517
518        let mut fields = HashMap::new();
519        fields.insert(
520            "BODY".to_string(),
521            QuillValue::from_json(json!("_italic_ text")),
522        );
523
524        let result = transform_markdown_fields(&fields, &schema);
525
526        let body = result.get("BODY").unwrap().as_str().unwrap();
527        assert!(body.contains("#emph[italic]"));
528    }
529
530    #[test]
531    fn test_transform_markdown_fields_collects_top_level_date_metadata() {
532        let schema = QuillValue::from_json(json!({
533            "type": "object",
534            "properties": {
535                "title": { "type": "string" },
536                "date": { "type": "string", "format": "date" },
537                "timestamp": { "type": "string", "format": "date-time" }
538            }
539        }));
540
541        let mut fields = HashMap::new();
542        fields.insert(
543            "title".to_string(),
544            QuillValue::from_json(json!("My Title")),
545        );
546
547        let result = transform_markdown_fields(&fields, &schema);
548        let meta = result.get("__meta__").expect("missing __meta__").as_json();
549
550        assert_eq!(meta["date_fields"], json!(["date"]));
551    }
552
553    #[test]
554    fn test_transform_markdown_fields_collects_card_date_metadata() {
555        let schema = QuillValue::from_json(json!({
556            "type": "object",
557            "properties": {},
558            "$defs": {
559                "indorsement_card": {
560                    "type": "object",
561                    "properties": {
562                        "date": { "type": "string", "format": "date" },
563                        "created_at": { "type": "string", "format": "date-time" },
564                        "BODY": { "type": "string", "contentMediaType": "text/markdown" }
565                    }
566                }
567            }
568        }));
569
570        let fields = HashMap::new();
571        let result = transform_markdown_fields(&fields, &schema);
572        let meta = result.get("__meta__").expect("missing __meta__").as_json();
573
574        assert_eq!(meta["card_date_fields"]["indorsement"], json!(["date"]));
575    }
576}