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