zenith_cli/commands/render/entry.rs
1//! The render command's error type, output artifacts, and public entry points.
2
3use std::path::Path;
4
5use zenith_core::{BytesAssetProvider, DataContext, Diagnostic, dim_to_px};
6use zenith_render::{
7 PdfOptions, render_pdf_multi_with, render_pdf_with, render_png, render_spread_png,
8};
9use zenith_scene::{Scene, compile_page};
10
11use crate::config::CliPolicyFlags;
12
13use super::assets::{build_asset_provider, build_font_provider, disk_diagnostics};
14use super::pipeline::{govern_compile_diagnostics, parse_validate, resolve_page_index};
15use super::text_source::resolve_text_sources;
16
17// ── Error type ────────────────────────────────────────────────────────────────
18
19/// Error produced by the render command.
20#[derive(Debug)]
21pub struct RenderCmdErr {
22 /// Human-readable message.
23 pub message: String,
24 /// Recommended exit code.
25 pub exit_code: u8,
26}
27
28impl RenderCmdErr {
29 pub(super) fn new(msg: impl Into<String>, exit_code: u8) -> Self {
30 Self {
31 message: msg.into(),
32 exit_code,
33 }
34 }
35}
36
37// ── Artifacts ─────────────────────────────────────────────────────────────────
38
39/// Scene JSON plus the compile-stage diagnostics that produced it.
40#[derive(Debug)]
41pub struct SceneArtifact {
42 /// The serialised scene JSON.
43 pub json: String,
44 /// Compile-stage diagnostics (advisories/warnings surfaced by `compile`).
45 pub diagnostics: Vec<Diagnostic>,
46}
47
48/// Rendered PNG bytes plus the compile-stage diagnostics that produced them.
49#[derive(Debug)]
50pub struct PngArtifact {
51 /// The encoded PNG bytes.
52 pub png: Vec<u8>,
53 /// Compile-stage diagnostics (advisories/warnings surfaced by `compile`).
54 pub diagnostics: Vec<Diagnostic>,
55}
56
57/// Rendered vector PDF bytes plus the compile-stage diagnostics that produced
58/// them.
59#[derive(Debug)]
60pub struct PdfArtifact {
61 /// The encoded PDF bytes.
62 pub pdf: Vec<u8>,
63 /// Compile-stage diagnostics (advisories/warnings surfaced by `compile`).
64 pub diagnostics: Vec<Diagnostic>,
65}
66
67// ── Entry points ──────────────────────────────────────────────────────────────
68
69/// Parse `src`, validate it with the merged diagnostic policy, compile the
70/// requested `page` (1-based), and return the scene JSON plus the
71/// compile-stage diagnostics.
72///
73/// `project_dir` is the `.zen` file's parent directory. When `Some`, font
74/// assets declared in the document are loaded and registered in the font
75/// provider so that `font.family` tokens referencing them resolve to the
76/// actual face rather than falling back to the bundled Noto fonts. When
77/// `None`, only the bundled fonts are available.
78///
79/// `data` is an optional data context for resolving `(data)"field"` property
80/// references at compile time. When `None`, data refs produce
81/// `data.missing_field` / `data.no_context` advisories (non-fatal).
82///
83/// `flags` carries the `--allow`/`--warn`/`--deny` CLI overrides; pass
84/// `&CliPolicyFlags::default()` when no flags are available (e.g. MCP).
85///
86/// Returns `Err` when:
87/// - A config file cannot be read (exit code 2).
88/// - The source fails to parse (exit code 2).
89/// - The document has validation errors (exit code 1).
90/// - The `page` is out of range (exit code 2).
91/// - Scene JSON serialisation fails (exit code 2).
92pub fn to_scene_json(
93 src: &str,
94 project_dir: Option<&Path>,
95 page: usize,
96 flags: &CliPolicyFlags,
97 data: Option<&DataContext>,
98) -> Result<SceneArtifact, RenderCmdErr> {
99 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
100 let mut text_src_diagnostics: Vec<Diagnostic> = Vec::new();
101 resolve_text_sources(&mut doc, project_dir, &mut text_src_diagnostics);
102 let fonts = build_font_provider(&doc, project_dir, false)?;
103 let page_index = resolve_page_index(&doc, page)?;
104 let compile_result = compile_page(&doc, &fonts, page_index, data);
105 let json = compile_result
106 .scene
107 .to_json()
108 .map_err(|e| RenderCmdErr::new(format!("scene serialisation error: {e}"), 2))?;
109 let mut diagnostics = text_src_diagnostics;
110 diagnostics.extend(disk_diagnostics(&doc, project_dir));
111 diagnostics.extend(govern_compile_diagnostics(
112 compile_result.diagnostics,
113 &policy,
114 ));
115 Ok(SceneArtifact { json, diagnostics })
116}
117
118/// Parse `src`, validate it, compile the scene, and return PNG bytes.
119///
120/// No image or SVG assets are loaded (an empty asset provider is used); any
121/// `image`/`svg` nodes are rendered without their content. Use
122/// [`to_png_with_dir`] to source asset bytes relative to the document's
123/// directory.
124///
125/// `page` is the 1-based page number to render. No CLI policy flags are
126/// applied; config files are still resolved (global only, no `start_dir`).
127/// No data context is supplied; data refs produce non-fatal advisories.
128///
129/// Returns `Err` when:
130/// - A config file cannot be read (exit code 2).
131/// - The source fails to parse (exit code 2).
132/// - The document has validation errors (exit code 1).
133/// - The `page` is out of range (exit code 2).
134/// - Rendering fails (exit code 2).
135pub fn to_png(src: &str, page: usize) -> Result<PngArtifact, RenderCmdErr> {
136 to_png_with_dir(src, None, page, false, &CliPolicyFlags::default(), None)
137}
138
139/// Like [`to_png`], but sources image and SVG asset bytes from `project_dir`
140/// (the `.zen` file's parent directory) when provided, and honours the merged
141/// diagnostic policy.
142///
143/// For each `image`- or `svg`-kind `AssetDecl`, the `src` is resolved relative
144/// to `project_dir` and read into a [`BytesAssetProvider`]. A read failure
145/// silently skips that asset; the missing file is instead surfaced as a hard
146/// `asset.missing` Error diagnostic on the returned artifact (which trips the
147/// render gate). When `project_dir` is `None` no assets are loaded.
148///
149/// When `locked` is set, every image and SVG asset's bytes are verified against
150/// their declared `sha256` and any mismatch, missing hash, or read failure is a
151/// hard error (exit code 2). When `project_dir` is `None` there are no assets,
152/// so `locked` is a no-op.
153///
154/// `page` is the 1-based page number to render.
155///
156/// `data` is an optional data context for resolving `(data)"field"` property
157/// references at compile time. When `None`, data refs produce non-fatal
158/// advisories.
159///
160/// `flags` carries the `--allow`/`--warn`/`--deny` CLI overrides; pass
161/// `&CliPolicyFlags::default()` when no flags are available (e.g. MCP).
162pub fn to_png_with_dir(
163 src: &str,
164 project_dir: Option<&Path>,
165 page: usize,
166 locked: bool,
167 flags: &CliPolicyFlags,
168 data: Option<&DataContext>,
169) -> Result<PngArtifact, RenderCmdErr> {
170 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
171 let mut text_src_diagnostics: Vec<Diagnostic> = Vec::new();
172 resolve_text_sources(&mut doc, project_dir, &mut text_src_diagnostics);
173 let fonts = build_font_provider(&doc, project_dir, locked)?;
174 let page_index = resolve_page_index(&doc, page)?;
175 let assets = match project_dir {
176 Some(dir) => build_asset_provider(&doc, dir, locked)?,
177 None => BytesAssetProvider::new(),
178 };
179 let compile_result = compile_page(&doc, &fonts, page_index, data);
180 let png = render_png(&compile_result.scene, &fonts, &assets)
181 .map_err(|e| RenderCmdErr::new(format!("render error: {e}"), 2))?;
182 let mut diagnostics = text_src_diagnostics;
183 diagnostics.extend(disk_diagnostics(&doc, project_dir));
184 diagnostics.extend(govern_compile_diagnostics(
185 compile_result.diagnostics,
186 &policy,
187 ));
188 Ok(PngArtifact { png, diagnostics })
189}
190
191/// Parse `src`, validate it with the merged diagnostic policy, compile the
192/// requested `page`, and render a vector PDF, sourcing image/SVG and font asset
193/// bytes from `project_dir` when provided (exactly like [`to_png_with_dir`]).
194///
195/// The PDF carries print box metadata (MediaBox / TrimBox / BleedBox /
196/// CropBox) and native DeviceCMYK for CMYK-origin colors. Output is
197/// deterministic. `page` is the 1-based page number.
198///
199/// `data` is an optional data context for resolving `(data)"field"` property
200/// references at compile time. When `None`, data refs produce non-fatal
201/// advisories.
202///
203/// `flags` carries the `--allow`/`--warn`/`--deny` CLI overrides; pass
204/// `&CliPolicyFlags::default()` when no flags are available (e.g. MCP).
205pub fn to_pdf_with_dir(
206 src: &str,
207 project_dir: Option<&Path>,
208 page: usize,
209 locked: bool,
210 subset: bool,
211 flags: &CliPolicyFlags,
212 data: Option<&DataContext>,
213) -> Result<PdfArtifact, RenderCmdErr> {
214 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
215 let mut text_src_diagnostics: Vec<Diagnostic> = Vec::new();
216 resolve_text_sources(&mut doc, project_dir, &mut text_src_diagnostics);
217 let fonts = build_font_provider(&doc, project_dir, locked)?;
218 let page_index = resolve_page_index(&doc, page)?;
219 let assets = match project_dir {
220 Some(dir) => build_asset_provider(&doc, dir, locked)?,
221 None => BytesAssetProvider::new(),
222 };
223 let compile_result = compile_page(&doc, &fonts, page_index, data);
224 let pdf = render_pdf_with(
225 &compile_result.scene,
226 &fonts,
227 &assets,
228 PdfOptions { subset },
229 );
230 let mut diagnostics = text_src_diagnostics;
231 diagnostics.extend(disk_diagnostics(&doc, project_dir));
232 diagnostics.extend(govern_compile_diagnostics(
233 compile_result.diagnostics,
234 &policy,
235 ));
236 Ok(PdfArtifact { pdf, diagnostics })
237}
238
239/// Parse `src`, validate it with the merged diagnostic policy, compile EVERY
240/// page (in document order, page 1 first), and render them into a single
241/// multi-page vector PDF, sourcing image/SVG and font asset bytes from
242/// `project_dir` when provided (exactly like [`to_pdf_with_dir`]).
243///
244/// This is the default `--pdf` behavior: a multi-page document produces a
245/// multi-page PDF. Use [`to_pdf_with_dir`] to select one explicit page.
246///
247/// Diagnostics from disk plus every page's governed compile diagnostics are
248/// merged in document order (page 1's first); duplicates are not removed. The
249/// PDF carries print box metadata and native DeviceCMYK exactly as the
250/// single-page path; a one-page document yields byte-identical output to
251/// [`to_pdf_with_dir`] for page 1.
252///
253/// `data` is applied to every page. `flags` carries the
254/// `--allow`/`--warn`/`--deny` CLI overrides; pass `&CliPolicyFlags::default()`
255/// when no flags are available (e.g. MCP).
256///
257/// Returns `Err` on parse failure (exit 2), validation errors (exit 1), an
258/// empty document (exit 2), or an asset/font failure (exit 2).
259pub fn to_pdf_all_pages_with_dir(
260 src: &str,
261 project_dir: Option<&Path>,
262 locked: bool,
263 subset: bool,
264 flags: &CliPolicyFlags,
265 data: Option<&DataContext>,
266) -> Result<PdfArtifact, RenderCmdErr> {
267 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
268 let mut diagnostics: Vec<Diagnostic> = Vec::new();
269 resolve_text_sources(&mut doc, project_dir, &mut diagnostics);
270 let fonts = build_font_provider(&doc, project_dir, locked)?;
271 let page_count = doc.body.pages.len();
272 if page_count == 0 {
273 return Err(RenderCmdErr::new("document has no pages to render", 2));
274 }
275 let assets = match project_dir {
276 Some(dir) => build_asset_provider(&doc, dir, locked)?,
277 None => BytesAssetProvider::new(),
278 };
279 let mut scenes: Vec<Scene> = Vec::with_capacity(page_count);
280 diagnostics.extend(disk_diagnostics(&doc, project_dir));
281 for page_index in 0..page_count {
282 let compile_result = compile_page(&doc, &fonts, page_index, data);
283 scenes.push(compile_result.scene);
284 diagnostics.extend(govern_compile_diagnostics(
285 compile_result.diagnostics,
286 &policy,
287 ));
288 }
289 let pdf = render_pdf_multi_with(&scenes, &fonts, &assets, PdfOptions { subset });
290 Ok(PdfArtifact { pdf, diagnostics })
291}
292
293/// Parse `src`, validate it with the merged diagnostic policy, and render
294/// EVERY page to PNG, returning one [`PngArtifact`] per page in document
295/// order (page 1 first).
296///
297/// Image and SVG asset bytes are sourced once from `project_dir` (shared
298/// across all pages). Returns `Err` on parse failure (exit 2), validation
299/// errors (exit 1), an empty document (exit 2), or a render failure (exit 2).
300/// When `locked` is set, image and SVG asset bytes are verified against their
301/// declared `sha256` (exit 2 on any mismatch/missing hash/read failure).
302///
303/// `data` is an optional data context for resolving `(data)"field"` property
304/// references at compile time (applied to every page). When `None`, data refs
305/// produce non-fatal advisories.
306///
307/// `flags` carries the `--allow`/`--warn`/`--deny` CLI overrides; pass
308/// `&CliPolicyFlags::default()` when no flags are available (e.g. MCP).
309pub fn to_png_all_pages(
310 src: &str,
311 project_dir: Option<&Path>,
312 locked: bool,
313 flags: &CliPolicyFlags,
314 data: Option<&DataContext>,
315) -> Result<Vec<PngArtifact>, RenderCmdErr> {
316 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
317 let mut text_src_diagnostics: Vec<Diagnostic> = Vec::new();
318 resolve_text_sources(&mut doc, project_dir, &mut text_src_diagnostics);
319 let fonts = build_font_provider(&doc, project_dir, locked)?;
320 let page_count = doc.body.pages.len();
321 if page_count == 0 {
322 return Err(RenderCmdErr::new("document has no pages to render", 2));
323 }
324 let assets = match project_dir {
325 Some(dir) => build_asset_provider(&doc, dir, locked)?,
326 None => BytesAssetProvider::new(),
327 };
328 let base_diagnostics: Vec<Diagnostic> = text_src_diagnostics
329 .into_iter()
330 .chain(disk_diagnostics(&doc, project_dir))
331 .collect();
332 let mut artifacts = Vec::with_capacity(page_count);
333 for page_index in 0..page_count {
334 let compile_result = compile_page(&doc, &fonts, page_index, data);
335 let png = render_png(&compile_result.scene, &fonts, &assets)
336 .map_err(|e| RenderCmdErr::new(format!("render error on page {page_index}: {e}"), 2))?;
337 let mut diagnostics = base_diagnostics.clone();
338 diagnostics.extend(govern_compile_diagnostics(
339 compile_result.diagnostics,
340 &policy,
341 ));
342 artifacts.push(PngArtifact { png, diagnostics });
343 }
344 Ok(artifacts)
345}
346
347/// Bundled render options for [`to_png_spread`], keeping its argument count
348/// within the lint limit (the spread path also takes two page indices and a
349/// gutter override). `Copy` so it cascades cheaply.
350#[derive(Clone, Copy)]
351pub struct SpreadRenderOpts<'a> {
352 /// Verify asset sha256 and fail on mismatch.
353 pub locked: bool,
354 /// Diagnostic-policy CLI flags.
355 pub flags: &'a CliPolicyFlags,
356 /// Optional data context for `(data)` references.
357 pub data: Option<&'a DataContext>,
358}
359
360/// Parse `src`, validate it with the merged diagnostic policy, compile pages
361/// `page_a` and `page_b` (both 1-based), composite them side by side (A on
362/// the left, B on the right), and return the spread PNG bytes plus the merged
363/// compile-stage diagnostics.
364///
365/// The output canvas width is `page_a_width + gutter_override_px + page_b_width`
366/// (or `page_a_width + doc.spread_gutter + page_b_width` when the override is
367/// `None`, defaulting to 0 when neither is set). A `gutter_px > 0` inserts that
368/// many fully-transparent columns between the two pages. Image/SVG/font asset
369/// bytes are sourced from `project_dir` (shared across both pages) exactly like
370/// [`to_png_with_dir`].
371///
372/// `data` is an optional data context for resolving `(data)"field"` property
373/// references at compile time (applied to both pages). When `None`, data refs
374/// produce non-fatal advisories.
375///
376/// `flags` carries the `--allow`/`--warn`/`--deny` CLI overrides; pass
377/// `&CliPolicyFlags::default()` when no flags are available (e.g. MCP).
378///
379/// Returns `Err` when:
380/// - A config file cannot be read (exit code 2).
381/// - The source fails to parse (exit code 2).
382/// - The document has validation errors (exit code 1).
383/// - Either page is out of range (exit code 2).
384/// - Rendering or compositing fails (exit code 2).
385pub fn to_png_spread(
386 src: &str,
387 project_dir: Option<&Path>,
388 page_a: usize,
389 page_b: usize,
390 gutter_override: Option<u32>,
391 opts: SpreadRenderOpts<'_>,
392) -> Result<PngArtifact, RenderCmdErr> {
393 let SpreadRenderOpts {
394 locked,
395 flags,
396 data,
397 } = opts;
398 let (mut doc, policy) = parse_validate(src, project_dir, flags)?;
399 let mut text_src_diagnostics: Vec<Diagnostic> = Vec::new();
400 resolve_text_sources(&mut doc, project_dir, &mut text_src_diagnostics);
401 let fonts = build_font_provider(&doc, project_dir, locked)?;
402 let index_a = resolve_page_index(&doc, page_a)?;
403 let index_b = resolve_page_index(&doc, page_b)?;
404 let assets = match project_dir {
405 Some(dir) => build_asset_provider(&doc, dir, locked)?,
406 None => BytesAssetProvider::new(),
407 };
408 // Resolve gutter: CLI override wins, then doc.spread_gutter, then 0.
409 let gutter_px = gutter_override.unwrap_or_else(|| {
410 doc.spread_gutter
411 .as_ref()
412 .and_then(|d| dim_to_px(d.value, &d.unit))
413 .map(|px| px.max(0.0) as u32)
414 .unwrap_or(0)
415 });
416 let compile_a = compile_page(&doc, &fonts, index_a, data);
417 let compile_b = compile_page(&doc, &fonts, index_b, data);
418 let png = render_spread_png(
419 &compile_a.scene,
420 &compile_b.scene,
421 gutter_px,
422 &fonts,
423 &assets,
424 )
425 .map_err(|e| RenderCmdErr::new(format!("spread render error: {e}"), 2))?;
426 let mut compile_diagnostics = compile_a.diagnostics;
427 compile_diagnostics.extend(compile_b.diagnostics);
428 let mut diagnostics = text_src_diagnostics;
429 diagnostics.extend(disk_diagnostics(&doc, project_dir));
430 diagnostics.extend(govern_compile_diagnostics(compile_diagnostics, &policy));
431 Ok(PngArtifact { png, diagnostics })
432}