Skip to main content

quillmark/orchestration/
quill.rs

1//! Renderable `Quill` — the engine-constructed composition of a
2//! [`QuillSource`] with a resolved backend.
3
4use indexmap::IndexMap;
5use std::sync::Arc;
6
7use quillmark_core::{
8    normalize::normalize_document, Backend, Card, Diagnostic, Document, Frontmatter, OutputFormat,
9    QuillSource, QuillValue, RenderError, RenderOptions, RenderResult, RenderSession, Sentinel,
10    Severity,
11};
12
13use crate::form::{self, Form, FormCard};
14
15/// Renderable quill. Composes an [`Arc<QuillSource>`] with a resolved
16/// [`Backend`]. Constructed by the engine; immutable once created.
17#[derive(Clone)]
18pub struct Quill {
19    source: Arc<QuillSource>,
20    backend: Arc<dyn Backend>,
21}
22
23struct PreparedRenderContext {
24    json_data: serde_json::Value,
25    plate_content: String,
26}
27
28impl Quill {
29    /// Construct a Quill from a source and a resolved backend.
30    ///
31    /// Engine-internal; external callers should use
32    /// [`crate::Quillmark::quill`] or [`crate::Quillmark::quill_from_path`].
33    pub(crate) fn new(source: Arc<QuillSource>, backend: Arc<dyn Backend>) -> Self {
34        Self { source, backend }
35    }
36
37    /// The underlying quill source.
38    pub fn source(&self) -> &QuillSource {
39        &self.source
40    }
41
42    /// The resolved backend identifier (e.g. `"typst"`).
43    pub fn backend_id(&self) -> &str {
44        self.backend.id()
45    }
46
47    /// Supported output formats for this quill's backend.
48    pub fn supported_formats(&self) -> &'static [OutputFormat] {
49        self.backend.supported_formats()
50    }
51
52    /// The quill's declared name.
53    pub fn name(&self) -> &str {
54        self.source.name()
55    }
56
57    /// Render a document to final artifacts.
58    ///
59    /// Pass `&RenderOptions::default()` for backend defaults (first supported
60    /// format, backend-chosen ppi, all pages).
61    pub fn render(
62        &self,
63        doc: &Document,
64        opts: &RenderOptions,
65    ) -> Result<RenderResult, RenderError> {
66        let session = self.open(doc)?;
67        let resolved = self.resolve_options(opts);
68        session.render(&resolved)
69    }
70
71    /// Open an iterative render session for this document.
72    pub fn open(&self, doc: &Document) -> Result<RenderSession, RenderError> {
73        let context = self.prepare_render_context(doc)?;
74        let warnings: Vec<_> = self.ref_mismatch_warning(doc).into_iter().collect();
75        let session =
76            self.backend
77                .open(&context.plate_content, &self.source, &context.json_data)?;
78        Ok(session.with_warnings(warnings))
79    }
80
81    fn resolve_options(&self, opts: &RenderOptions) -> RenderOptions {
82        let output_format = opts
83            .output_format
84            .or_else(|| self.backend.supported_formats().first().copied());
85        RenderOptions {
86            output_format,
87            ppi: opts.ppi,
88            pages: opts.pages.clone(),
89        }
90    }
91
92    /// Compile a Document to JSON data suitable for the backend.
93    ///
94    /// Applies coercion, validation, normalization, and schema defaults, then
95    /// calls [`Document::to_plate_json`] to produce the wire format.
96    pub fn compile_data(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
97        // Coerce main-card frontmatter fields against the schema.
98        let main_fields_map = doc.main().frontmatter().to_index_map();
99        let coerced_frontmatter = self
100            .source
101            .config()
102            .coerce_frontmatter(&main_fields_map)
103            .map_err(|e| RenderError::ValidationFailed {
104                diag: Box::new(
105                    Diagnostic::new(Severity::Error, e.to_string())
106                        .with_code("validation::coercion_failed".to_string())
107                        .with_hint(
108                            "Ensure all fields can be coerced to their declared types".to_string(),
109                        ),
110                ),
111            })?;
112
113        // Coerce card fields against per-card schemas.
114        let mut coerced_cards: Vec<Card> = Vec::new();
115        for card in doc.cards() {
116            let card_fields_map = card.frontmatter().to_index_map();
117            let coerced_fields = self
118                .source
119                .config()
120                .coerce_card(&card.tag(), &card_fields_map)
121                .map_err(|e| RenderError::ValidationFailed {
122                    diag: Box::new(
123                        Diagnostic::new(Severity::Error, e.to_string())
124                            .with_code("validation::coercion_failed".to_string())
125                            .with_hint(
126                                "Ensure all card fields can be coerced to their declared types"
127                                    .to_string(),
128                            ),
129                    ),
130                })?;
131            coerced_cards.push(Card::new_with_sentinel(
132                Sentinel::Card(card.tag()),
133                Frontmatter::from_index_map(coerced_fields),
134                card.body().to_string(),
135            ));
136        }
137
138        let coerced_main = Card::new_with_sentinel(
139            Sentinel::Main(doc.quill_reference().clone()),
140            Frontmatter::from_index_map(coerced_frontmatter),
141            doc.main().body().to_string(),
142        );
143        let coerced_doc =
144            Document::from_main_and_cards(coerced_main, coerced_cards, doc.warnings().to_vec());
145
146        self.validate_document(&coerced_doc)?;
147
148        // Normalize: strip bidi + fix HTML comment fences in body regions.
149        let normalized = normalize_document(coerced_doc)?;
150
151        // Apply schema defaults to frontmatter.
152        let normalized_main_map = normalized.main().frontmatter().to_index_map();
153        let frontmatter_with_defaults = self.apply_frontmatter_defaults(&normalized_main_map);
154
155        // Apply per-card defaults.
156        let cards_with_defaults: Vec<Card> = normalized
157            .cards()
158            .iter()
159            .map(|card| {
160                let card_map = card.frontmatter().to_index_map();
161                let fields_with_defaults = self.apply_card_defaults(&card.tag(), &card_map);
162                Card::new_with_sentinel(
163                    Sentinel::Card(card.tag()),
164                    Frontmatter::from_index_map(fields_with_defaults),
165                    card.body().to_string(),
166                )
167            })
168            .collect();
169
170        // Rebuild document with defaults applied.
171        let final_main = Card::new_with_sentinel(
172            Sentinel::Main(normalized.quill_reference().clone()),
173            Frontmatter::from_index_map(frontmatter_with_defaults),
174            normalized.main().body().to_string(),
175        );
176        let final_doc = Document::from_main_and_cards(
177            final_main,
178            cards_with_defaults,
179            normalized.warnings().to_vec(),
180        );
181
182        // Build the plate wire format.
183        Ok(final_doc.to_plate_json())
184    }
185
186    fn prepare_render_context(&self, doc: &Document) -> Result<PreparedRenderContext, RenderError> {
187        Ok(PreparedRenderContext {
188            json_data: self.compile_data(doc)?,
189            plate_content: self.plate_content().unwrap_or_default(),
190        })
191    }
192
193    fn ref_mismatch_warning(&self, doc: &Document) -> Option<Diagnostic> {
194        let doc_ref = doc.quill_reference().name.as_str();
195        if doc_ref != self.source.name() {
196            Some(
197                Diagnostic::new(
198                    Severity::Warning,
199                    format!(
200                        "document declares QUILL '{}' but was rendered with '{}'",
201                        doc_ref,
202                        self.source.name()
203                    ),
204                )
205                .with_code("quill::ref_mismatch".to_string())
206                .with_hint(
207                    "the QUILL field is informational; ensure you are rendering with the intended quill"
208                        .to_string(),
209                ),
210            )
211        } else {
212            None
213        }
214    }
215
216    fn apply_frontmatter_defaults(
217        &self,
218        frontmatter: &IndexMap<String, QuillValue>,
219    ) -> IndexMap<String, QuillValue> {
220        let mut result = frontmatter.clone();
221        for (field_name, default_value) in self.source.config().main.defaults() {
222            if !result.contains_key(&field_name) {
223                result.insert(field_name, default_value);
224            }
225        }
226        result
227    }
228
229    fn apply_card_defaults(
230        &self,
231        card_tag: &str,
232        fields: &IndexMap<String, QuillValue>,
233    ) -> IndexMap<String, QuillValue> {
234        let mut result = fields.clone();
235        if let Some(card) = self.source.config().card_type(card_tag) {
236            for (field_name, default_value) in card.defaults() {
237                if !result.contains_key(&field_name) {
238                    result.insert(field_name, default_value);
239                }
240            }
241        }
242        result
243    }
244
245    fn plate_content(&self) -> Option<String> {
246        self.source
247            .plate()
248            .filter(|s| !s.is_empty())
249            .map(str::to_string)
250    }
251
252    /// The schema-aware form view of `doc` — the whole-document snapshot
253    /// rendered through this quill's schema.
254    ///
255    /// For each schema-declared field on the main card and on every
256    /// recognised card, the returned [`Form`] records the current value, the
257    /// schema default, and a [`form::FormFieldSource`] label.
258    ///
259    /// **Snapshot semantics.** The result is a read-only snapshot — re-call
260    /// after editing `doc`.
261    ///
262    /// **Unknown card tags** are dropped from [`Form::cards`] and surface as
263    /// `form::unknown_card_tag` diagnostics. Validation errors are appended
264    /// as `form::validation_error` diagnostics; the view itself is never
265    /// altered or filtered by validation failures.
266    pub fn form(&self, doc: &Document) -> Form {
267        form::build_form(self, doc)
268    }
269
270    /// A blank form for the main card — no document values supplied. Every
271    /// declared field's source is [`form::FormFieldSource::Default`] (when
272    /// the schema declares a default) or [`form::FormFieldSource::Missing`].
273    ///
274    /// Useful as a starting state for a fresh document, or for previewing the
275    /// main-card form without a document in hand.
276    pub fn blank_main(&self) -> FormCard {
277        FormCard::blank(&self.source.config().main)
278    }
279
280    /// A blank form for a card of the given type — no document values
281    /// supplied. Returns `None` if `card_type` is not declared in the
282    /// quill's schema.
283    ///
284    /// This is the "user is about to add a new card" view: the UI can render
285    /// the form before the card is committed to the document.
286    pub fn blank_card(&self, card_type: &str) -> Option<FormCard> {
287        form::blank_card_for_tag(self, card_type)
288    }
289
290    /// Perform a dry-run validation without backend compilation.
291    pub fn dry_run(&self, doc: &Document) -> Result<(), RenderError> {
292        let main_fields_map = doc.main().frontmatter().to_index_map();
293        let coerced_frontmatter = self
294            .source
295            .config()
296            .coerce_frontmatter(&main_fields_map)
297            .map_err(|e| RenderError::ValidationFailed {
298                diag: Box::new(
299                    Diagnostic::new(Severity::Error, e.to_string())
300                        .with_code("validation::coercion_failed".to_string())
301                        .with_hint(
302                            "Ensure all fields and card values can be coerced to their declared types"
303                                .to_string(),
304                        ),
305                ),
306            })?;
307        let mut coerced_cards: Vec<Card> = Vec::new();
308        for card in doc.cards() {
309            let card_fields_map = card.frontmatter().to_index_map();
310            let coerced_fields = self
311                .source
312                .config()
313                .coerce_card(&card.tag(), &card_fields_map)
314                .map_err(|e| RenderError::ValidationFailed {
315                    diag: Box::new(
316                        Diagnostic::new(Severity::Error, e.to_string())
317                            .with_code("validation::coercion_failed".to_string())
318                            .with_hint(
319                                "Ensure all card fields can be coerced to their declared types"
320                                    .to_string(),
321                            ),
322                    ),
323                })?;
324            coerced_cards.push(Card::new_with_sentinel(
325                Sentinel::Card(card.tag()),
326                Frontmatter::from_index_map(coerced_fields),
327                card.body().to_string(),
328            ));
329        }
330        let coerced_main = Card::new_with_sentinel(
331            Sentinel::Main(doc.quill_reference().clone()),
332            Frontmatter::from_index_map(coerced_frontmatter),
333            doc.main().body().to_string(),
334        );
335        let coerced_doc =
336            Document::from_main_and_cards(coerced_main, coerced_cards, doc.warnings().to_vec());
337        self.validate_document(&coerced_doc)?;
338        Ok(())
339    }
340
341    fn validate_document(&self, doc: &Document) -> Result<(), RenderError> {
342        match self.source.config().validate_document(doc) {
343            Ok(_) => Ok(()),
344            Err(errors) => {
345                let error_message = errors
346                    .into_iter()
347                    .map(|e| format!("- {}", e))
348                    .collect::<Vec<_>>()
349                    .join("\n");
350                Err(RenderError::ValidationFailed {
351                    diag: Box::new(
352                        Diagnostic::new(Severity::Error, error_message)
353                            .with_code("validation::document_invalid".to_string())
354                            .with_hint(
355                                "Ensure all required fields are present and have correct types"
356                                    .to_string(),
357                            ),
358                    ),
359                })
360            }
361        }
362    }
363}
364
365impl std::fmt::Debug for Quill {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("Quill")
368            .field("name", &self.source.name())
369            .field("backend", &self.backend.id())
370            .finish()
371    }
372}