quillmark/
orchestration.rs

1//! # Orchestration
2//!
3//! Orchestrates the Quillmark engine and its workflows.
4//!
5//! ---
6//!
7//! # Quillmark Engine
8//!
9//! High-level engine for orchestrating backends and quills.
10//!
11//! [`Quillmark`] manages the registration of backends and quills, and provides
12//! a convenient way to create workflows. Backends are automatically registered
13//! based on enabled crate features.
14//!
15//! ## Backend Auto-Registration
16//!
17//! When a [`Quillmark`] engine is created with [`Quillmark::new`], it automatically
18//! registers all backends based on enabled features:
19//!
20//! - **typst** (default) - Typst backend for PDF/SVG rendering
21//!
22//! ## Workflow (Engine Level)
23//!
24//! 1. Create an engine with [`Quillmark::new`]
25//! 2. Register quills with [`Quillmark::register_quill()`]
26//! 3. Load workflows with [`Quillmark::workflow_from_quill_name()`] or [`Quillmark::workflow_from_parsed()`]
27//! 4. Render documents using the workflow
28//!
29//! ## Examples
30//!
31//! ### Basic Usage
32//!
33//! ```no_run
34//! use quillmark::{Quillmark, Quill, OutputFormat, ParsedDocument};
35//!
36//! // Step 1: Create engine with auto-registered backends
37//! let mut engine = Quillmark::new();
38//!
39//! // Step 2: Create and register quills
40//! let quill = Quill::from_path("path/to/quill").unwrap();
41//! engine.register_quill(quill);
42//!
43//! // Step 3: Parse markdown
44//! let markdown = "# Hello";
45//! let parsed = ParsedDocument::from_markdown(markdown).unwrap();
46//!
47//! // Step 4: Load workflow by quill name and render
48//! let workflow = engine.workflow_from_quill_name("my-quill").unwrap();
49//! let result = workflow.render(&parsed, Some(OutputFormat::Pdf)).unwrap();
50//! ```
51//!
52//! ### Loading by Reference
53//!
54//! ```no_run
55//! # use quillmark::{Quillmark, Quill, ParsedDocument};
56//! # let mut engine = Quillmark::new();
57//! let quill = Quill::from_path("path/to/quill").unwrap();
58//! engine.register_quill(quill.clone());
59//!
60//! // Load by name
61//! let workflow1 = engine.workflow_from_quill_name("my-quill").unwrap();
62//!
63//! // Load by object (doesn't need to be registered)
64//! let workflow2 = engine.workflow_from_quill(&quill).unwrap();
65//! ```
66//!
67//! ### Inspecting Engine State
68//!
69//! ```no_run
70//! # use quillmark::Quillmark;
71//! # let engine = Quillmark::new();
72//! println!("Available backends: {:?}", engine.registered_backends());
73//! println!("Registered quills: {:?}", engine.registered_quills());
74//! ```
75//!
76//! ---
77//!
78//! # Workflow
79//!
80//! Sealed workflow for rendering Markdown documents.
81//!
82//! [`Workflow`] encapsulates the complete rendering pipeline from Markdown to final artifacts.
83//! It manages the backend, quill template, and dynamic assets, providing methods for
84//! rendering at different stages of the pipeline.
85//!
86//! ## Rendering Pipeline
87//!
88//! The workflow supports rendering at three levels:
89//!
90//! 1. **Full render** ([`Workflow::render()`]) - Compose with template → Compile to artifacts (parsing done separately)
91//! 2. **Content render** ([`Workflow::render_source()`]) - Skip parsing, render pre-composed content
92//! 3. **Glue only** ([`Workflow::process_glue_parsed()`]) - Compose from parsed document, return template output
93//!
94//! ## Examples
95//!
96//! ### Basic Rendering
97//!
98//! ```no_run
99//! # use quillmark::{Quillmark, OutputFormat, ParsedDocument};
100//! # let mut engine = Quillmark::new();
101//! # let quill = quillmark::Quill::from_path("path/to/quill").unwrap();
102//! # engine.register_quill(quill);
103//! let workflow = engine.workflow_from_quill_name("my-quill").unwrap();
104//!
105//! let markdown = r#"---
106//! title: "My Document"
107//! author: "Alice"
108//! ---
109//!
110//! # Introduction
111//!
112//! This is my document.
113//! "#;
114//!
115//! let parsed = ParsedDocument::from_markdown(markdown).unwrap();
116//! let result = workflow.render(&parsed, Some(OutputFormat::Pdf)).unwrap();
117//! ```
118//!
119//! ### Dynamic Assets (Builder Pattern)
120//!
121//! ```no_run
122//! # use quillmark::{Quillmark, OutputFormat, ParsedDocument};
123//! # let mut engine = Quillmark::new();
124//! # let quill = quillmark::Quill::from_path("path/to/quill").unwrap();
125//! # engine.register_quill(quill);
126//! # let markdown = "# Report";
127//! # let parsed = ParsedDocument::from_markdown(markdown).unwrap();
128//! let workflow = engine.workflow_from_quill_name("my-quill").unwrap()
129//!     .with_asset("logo.png", vec![/* PNG bytes */]).unwrap()
130//!     .with_asset("chart.svg", vec![/* SVG bytes */]).unwrap();
131//!
132//! let result = workflow.render(&parsed, Some(OutputFormat::Pdf)).unwrap();
133//! ```
134//!
135//! ### Dynamic Fonts (Builder Pattern)
136//!
137//! ```no_run
138//! # use quillmark::{Quillmark, OutputFormat, ParsedDocument};
139//! # let mut engine = Quillmark::new();
140//! # let quill = quillmark::Quill::from_path("path/to/quill").unwrap();
141//! # engine.register_quill(quill);
142//! # let markdown = "# Report";
143//! # let parsed = ParsedDocument::from_markdown(markdown).unwrap();
144//! let workflow = engine.workflow_from_quill_name("my-quill").unwrap()
145//!     .with_font("custom-font.ttf", vec![/* TTF bytes */]).unwrap()
146//!     .with_font("another-font.otf", vec![/* OTF bytes */]).unwrap();
147//!
148//! let result = workflow.render(&parsed, Some(OutputFormat::Pdf)).unwrap();
149//! ```
150//!
151//! ### Inspecting Workflow Properties
152//!
153//! ```no_run
154//! # use quillmark::Quillmark;
155//! # let mut engine = Quillmark::new();
156//! # let quill = quillmark::Quill::from_path("path/to/quill").unwrap();
157//! # engine.register_quill(quill);
158//! let workflow = engine.workflow_from_quill_name("my-quill").unwrap();
159//!
160//! println!("Backend: {}", workflow.backend_id());
161//! println!("Quill: {}", workflow.quill_name());
162//! println!("Formats: {:?}", workflow.supported_formats());
163//! ```
164
165use quillmark_core::{
166    decompose, Backend, Glue, OutputFormat, ParsedDocument, Quill, RenderError, RenderOptions,
167    RenderResult,
168};
169use std::collections::HashMap;
170
171/// Ergonomic reference to a Quill by name or object.
172pub enum QuillRef<'a> {
173    /// Reference to a quill by its registered name
174    Name(&'a str),
175    /// Reference to a borrowed Quill object
176    Object(&'a Quill),
177}
178
179impl<'a> From<&'a Quill> for QuillRef<'a> {
180    fn from(quill: &'a Quill) -> Self {
181        QuillRef::Object(quill)
182    }
183}
184
185impl<'a> From<&'a str> for QuillRef<'a> {
186    fn from(name: &'a str) -> Self {
187        QuillRef::Name(name)
188    }
189}
190
191impl<'a> From<&'a String> for QuillRef<'a> {
192    fn from(name: &'a String) -> Self {
193        QuillRef::Name(name.as_str())
194    }
195}
196
197impl<'a> From<&'a std::borrow::Cow<'a, str>> for QuillRef<'a> {
198    fn from(name: &'a std::borrow::Cow<'a, str>) -> Self {
199        QuillRef::Name(name.as_ref())
200    }
201}
202
203/// High-level engine for orchestrating backends and quills. See [module docs](self) for usage patterns.
204pub struct Quillmark {
205    backends: HashMap<String, Box<dyn Backend>>,
206    quills: HashMap<String, Quill>,
207}
208
209impl Quillmark {
210    /// Create a new Quillmark with auto-registered backends based on enabled features.
211    pub fn new() -> Self {
212        #[allow(unused_mut)]
213        let mut backends: HashMap<String, Box<dyn Backend>> = HashMap::new();
214
215        // Auto-register backends based on enabled features
216        #[cfg(feature = "typst")]
217        {
218            let backend = Box::new(quillmark_typst::TypstBackend::default());
219            backends.insert(backend.id().to_string(), backend);
220        }
221
222        Self {
223            backends,
224            quills: HashMap::new(),
225        }
226    }
227
228    /// Register a quill template with the engine by name.
229    pub fn register_quill(&mut self, quill: Quill) {
230        let name = quill.name.clone();
231        self.quills.insert(name, quill);
232    }
233
234    /// Load a workflow from a parsed document that contains a quill tag
235    pub fn workflow_from_parsed(&self, parsed: &ParsedDocument) -> Result<Workflow, RenderError> {
236        let quill_name = parsed.quill_tag().ok_or_else(|| {
237            RenderError::Other(
238                "No QUILL field found in parsed document. Add `QUILL: <name>` to the markdown frontmatter.".into(),
239            )
240        })?;
241        self.workflow_from_quill_name(quill_name)
242    }
243
244    /// Load a workflow by quill reference (name or object)
245    pub fn workflow_from_quill<'a>(
246        &self,
247        quill_ref: impl Into<QuillRef<'a>>,
248    ) -> Result<Workflow, RenderError> {
249        let quill_ref = quill_ref.into();
250
251        // Get the quill reference based on the parameter type
252        let quill = match quill_ref {
253            QuillRef::Name(name) => {
254                // Look up the quill by name
255                self.quills.get(name).ok_or_else(|| {
256                    RenderError::Other(format!("Quill '{}' not registered", name).into())
257                })?
258            }
259            QuillRef::Object(quill) => {
260                // Use the provided quill directly
261                quill
262            }
263        };
264
265        // Get backend ID from quill metadata
266        let backend_id = quill
267            .metadata
268            .get("backend")
269            .and_then(|v| v.as_str())
270            .ok_or_else(|| {
271                RenderError::Other(
272                    format!("Quill '{}' does not specify a backend", quill.name).into(),
273                )
274            })?;
275
276        // Get the backend by ID
277        let backend = self.backends.get(backend_id).ok_or_else(|| {
278            RenderError::Other(
279                format!("Backend '{}' not registered or not enabled", backend_id).into(),
280            )
281        })?;
282
283        // Clone the backend and quill for the workflow
284        // Note: We need to box clone the backend trait object
285        let backend_clone = self.clone_backend(backend.as_ref());
286        let quill_clone = quill.clone();
287
288        Workflow::new(backend_clone, quill_clone)
289    }
290
291    /// Load a workflow by quill name
292    pub fn workflow_from_quill_name(&self, name: &str) -> Result<Workflow, RenderError> {
293        self.workflow_from_quill(name)
294    }
295
296    /// Helper method to clone a backend (trait object cloning workaround)
297    fn clone_backend(&self, backend: &dyn Backend) -> Box<dyn Backend> {
298        // For each backend, we need to instantiate a new one
299        // This is a workaround since we can't clone trait objects directly
300        match backend.id() {
301            #[cfg(feature = "typst")]
302            "typst" => Box::new(quillmark_typst::TypstBackend::default()),
303            _ => panic!("Unknown backend: {}", backend.id()),
304        }
305    }
306
307    /// Get a list of registered backend IDs.
308    pub fn registered_backends(&self) -> Vec<&str> {
309        self.backends.keys().map(|s| s.as_str()).collect()
310    }
311
312    /// Get a list of registered quill names.
313    pub fn registered_quills(&self) -> Vec<&str> {
314        self.quills.keys().map(|s| s.as_str()).collect()
315    }
316}
317
318impl Default for Quillmark {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324/// Sealed workflow for rendering Markdown documents. See [module docs](self) for usage patterns.
325pub struct Workflow {
326    backend: Box<dyn Backend>,
327    quill: Quill,
328    dynamic_assets: HashMap<String, Vec<u8>>,
329    dynamic_fonts: HashMap<String, Vec<u8>>,
330}
331
332impl Workflow {
333    /// Create a new Workflow with the specified backend and quill.
334    pub fn new(backend: Box<dyn Backend>, quill: Quill) -> Result<Self, RenderError> {
335        // Since Quill::from_path() now automatically validates, we don't need to validate again
336        Ok(Self {
337            backend,
338            quill,
339            dynamic_assets: HashMap::new(),
340            dynamic_fonts: HashMap::new(),
341        })
342    }
343
344    /// Render Markdown with YAML frontmatter to output artifacts. See [module docs](self) for examples.
345    pub fn render(
346        &self,
347        parsed: &ParsedDocument,
348        format: Option<OutputFormat>,
349    ) -> Result<RenderResult, RenderError> {
350        let glue_output = self.process_glue_parsed(parsed)?;
351
352        // Prepare quill with dynamic assets
353        let prepared_quill = self.prepare_quill_with_assets();
354
355        // Pass prepared quill to backend
356        self.render_source_with_quill(&glue_output, format, &prepared_quill)
357    }
358
359    /// Render pre-processed glue content, skipping parsing and template composition.
360    pub fn render_source(
361        &self,
362        content: &str,
363        format: Option<OutputFormat>,
364    ) -> Result<RenderResult, RenderError> {
365        // Prepare quill with dynamic assets
366        let prepared_quill = self.prepare_quill_with_assets();
367        self.render_source_with_quill(content, format, &prepared_quill)
368    }
369
370    /// Internal method to render content with a specific quill
371    fn render_source_with_quill(
372        &self,
373        content: &str,
374        format: Option<OutputFormat>,
375        quill: &Quill,
376    ) -> Result<RenderResult, RenderError> {
377        // Compile using backend
378        let format = if format.is_some() {
379            format
380        } else {
381            // Default to first supported format if none specified
382            let supported = self.backend.supported_formats();
383            if !supported.is_empty() {
384                println!("Defaulting to output format: {:?}", supported[0]);
385                Some(supported[0])
386            } else {
387                None
388            }
389        };
390        // Compile using backend
391        let render_opts = RenderOptions {
392            output_format: format,
393        };
394
395        let artifacts = self.backend.compile(content, quill, &render_opts)?;
396        Ok(RenderResult::new(artifacts))
397    }
398
399    /// Process Markdown through the glue template without compilation, returning the composed output.
400    pub fn process_glue(&self, markdown: &str) -> Result<String, RenderError> {
401        let parsed_doc = decompose(markdown).map_err(|e| RenderError::InvalidFrontmatter {
402            diag: quillmark_core::error::Diagnostic::new(
403                quillmark_core::error::Severity::Error,
404                format!("Failed to parse markdown: {}", e),
405            ),
406            source: Some(anyhow::anyhow!(e)),
407        })?;
408
409        self.process_glue_parsed(&parsed_doc)
410    }
411
412    /// Process a parsed document through the glue template without compilation
413    pub fn process_glue_parsed(&self, parsed: &ParsedDocument) -> Result<String, RenderError> {
414        let mut glue = Glue::new(self.quill.glue_template.clone());
415        self.backend.register_filters(&mut glue);
416        let glue_output = glue
417            .compose(parsed.fields().clone())
418            .map_err(|e| RenderError::from(e))?;
419        Ok(glue_output)
420    }
421
422    /// Get the backend identifier (e.g., "typst").
423    pub fn backend_id(&self) -> &str {
424        self.backend.id()
425    }
426
427    /// Get the supported output formats for this workflow's backend.
428    pub fn supported_formats(&self) -> &'static [OutputFormat] {
429        self.backend.supported_formats()
430    }
431
432    /// Get the quill name used by this workflow.
433    pub fn quill_name(&self) -> &str {
434        &self.quill.name
435    }
436
437    /// Return the list of dynamic asset filenames currently stored in the workflow.
438    ///
439    /// This is primarily a debugging helper so callers (for example wasm bindings)
440    /// can inspect which assets have been added via `with_asset` / `with_assets`.
441    pub fn dynamic_asset_names(&self) -> Vec<String> {
442        self.dynamic_assets.keys().cloned().collect()
443    }
444
445    /// Add a dynamic asset to the workflow (builder pattern). See [module docs](self) for examples.
446    pub fn with_asset(
447        mut self,
448        filename: impl Into<String>,
449        contents: impl Into<Vec<u8>>,
450    ) -> Result<Self, RenderError> {
451        let filename = filename.into();
452
453        // Check for collision
454        if self.dynamic_assets.contains_key(&filename) {
455            return Err(RenderError::DynamicAssetCollision {
456                filename: filename.clone(),
457                message: format!(
458                    "Dynamic asset '{}' already exists. Each asset filename must be unique.",
459                    filename
460                ),
461            });
462        }
463
464        self.dynamic_assets.insert(filename, contents.into());
465        Ok(self)
466    }
467
468    /// Add multiple dynamic assets at once (builder pattern).
469    pub fn with_assets(
470        mut self,
471        assets: impl IntoIterator<Item = (String, Vec<u8>)>,
472    ) -> Result<Self, RenderError> {
473        for (filename, contents) in assets {
474            self = self.with_asset(filename, contents)?;
475        }
476        Ok(self)
477    }
478
479    /// Clear all dynamic assets from the workflow (builder pattern).
480    pub fn clear_assets(mut self) -> Self {
481        self.dynamic_assets.clear();
482        self
483    }
484
485    /// Return the list of dynamic font filenames currently stored in the workflow.
486    ///
487    /// This is primarily a debugging helper so callers (for example wasm bindings)
488    /// can inspect which fonts have been added via `with_font` / `with_fonts`.
489    pub fn dynamic_font_names(&self) -> Vec<String> {
490        self.dynamic_fonts.keys().cloned().collect()
491    }
492
493    /// Add a dynamic font to the workflow (builder pattern). Fonts are saved to assets/ with DYNAMIC_FONT__ prefix.
494    pub fn with_font(
495        mut self,
496        filename: impl Into<String>,
497        contents: impl Into<Vec<u8>>,
498    ) -> Result<Self, RenderError> {
499        let filename = filename.into();
500
501        // Check for collision
502        if self.dynamic_fonts.contains_key(&filename) {
503            return Err(RenderError::DynamicFontCollision {
504                filename: filename.clone(),
505                message: format!(
506                    "Dynamic font '{}' already exists. Each font filename must be unique.",
507                    filename
508                ),
509            });
510        }
511
512        self.dynamic_fonts.insert(filename, contents.into());
513        Ok(self)
514    }
515
516    /// Add multiple dynamic fonts at once (builder pattern).
517    pub fn with_fonts(
518        mut self,
519        fonts: impl IntoIterator<Item = (String, Vec<u8>)>,
520    ) -> Result<Self, RenderError> {
521        for (filename, contents) in fonts {
522            self = self.with_font(filename, contents)?;
523        }
524        Ok(self)
525    }
526
527    /// Clear all dynamic fonts from the workflow (builder pattern).
528    pub fn clear_fonts(mut self) -> Self {
529        self.dynamic_fonts.clear();
530        self
531    }
532
533    /// Internal method to prepare a quill with dynamic assets and fonts
534    fn prepare_quill_with_assets(&self) -> Quill {
535        use quillmark_core::FileTreeNode;
536
537        let mut quill = self.quill.clone();
538
539        // Add dynamic assets to the cloned quill's file system
540        for (filename, contents) in &self.dynamic_assets {
541            let prefixed_path = format!("assets/DYNAMIC_ASSET__{}", filename);
542            let file_node = FileTreeNode::File {
543                contents: contents.clone(),
544            };
545            // Ignore errors if insertion fails (e.g., path already exists)
546            let _ = quill.files.insert(&prefixed_path, file_node);
547        }
548
549        // Add dynamic fonts to the cloned quill's file system
550        for (filename, contents) in &self.dynamic_fonts {
551            let prefixed_path = format!("assets/DYNAMIC_FONT__{}", filename);
552            let file_node = FileTreeNode::File {
553                contents: contents.clone(),
554            };
555            // Ignore errors if insertion fails (e.g., path already exists)
556            let _ = quill.files.insert(&prefixed_path, file_node);
557        }
558
559        quill
560    }
561}