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}