carta_core/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![warn(missing_docs)]
3//! Shared carta core: the conversion traits, their option types, and the common error type.
4//!
5//! [`Reader`] turns input text into a [`Document`]; [`Writer`] turns a [`Document`] back into
6//! output text. Readers and writers depend only on the AST contract and this crate, so input and
7//! output formats stay independent.
8
9use std::fmt;
10use std::io;
11use std::sync::Arc;
12
13use carta_ast::{Block, Document, Inline};
14
15pub mod budget;
16#[cfg(feature = "container")]
17#[cfg_attr(docsrs, doc(cfg(feature = "container")))]
18pub mod container;
19pub mod extensions;
20pub mod media;
21pub mod sections;
22pub mod stack;
23#[cfg(feature = "template")]
24#[cfg_attr(docsrs, doc(cfg(feature = "template")))]
25pub mod template;
26pub mod walk;
27
28pub use extensions::{Extension, Extensions, presets};
29pub use media::{MediaBag, MediaItem};
30pub use stack::{DEEP_STACK, DeepStack, on_deep_stack};
31
32/// The error type returned across the conversion pipeline.
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35 /// JSON input or output could not be (de)serialized.
36 #[cfg(feature = "serde")]
37 #[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
38 #[error("JSON error: {0}")]
39 Json(#[from] serde_json::Error),
40 /// An I/O operation failed.
41 #[error("I/O error: {0}")]
42 Io(#[from] io::Error),
43 /// Input handed to a text reader was not valid UTF-8.
44 #[error("input is not valid UTF-8: {0}")]
45 InvalidUtf8(#[from] std::str::Utf8Error),
46 /// A text-only API was asked for a format whose output is binary; use the byte-capable API.
47 #[error("format '{0}' converts binary data; use the byte-capable API (convert)")]
48 BinaryFormat(String),
49 /// The named format is not recognized.
50 #[error("unsupported format: {0}")]
51 UnsupportedFormat(String),
52 /// The named format is recognized but not compiled into this build.
53 #[error("format '{0}' is recognized but not enabled in this build")]
54 FormatNotEnabled(String),
55 /// A `+`/`-` toggle named an extension that is not modeled.
56 #[error("unknown extension: {0}")]
57 UnknownExtension(String),
58 /// A modeled extension does not apply to the given format.
59 #[error(
60 "The extension '{extension}' is not supported for {format}.\nUse --list-extensions={format} to list supported extensions."
61 )]
62 UnsupportedExtension {
63 /// The extension the format does not support.
64 extension: String,
65 /// The format that does not support the extension.
66 format: String,
67 },
68 /// Document metadata could not be parsed.
69 #[error("invalid document metadata: {0}")]
70 InvalidMetadata(String),
71 /// A standalone template failed to parse or render.
72 #[error("template error: {0}")]
73 Template(String),
74 /// The document holds content the target format cannot represent.
75 #[error("cannot represent this content in the target format: {0}")]
76 Unrepresentable(String),
77 /// Rendering the document would produce output out of all proportion to its size, which only
78 /// nesting a self-multiplying construct (a table inside a table cell) can cause.
79 #[error("rendering this document would produce output far larger than the document itself")]
80 OutputTooLarge,
81 /// Building or reading a container archive failed.
82 #[error("container error: {0}")]
83 Container(String),
84 /// A document filter failed to run or returned an unusable result.
85 #[error("filter error: {0}")]
86 Filter(String),
87 /// A syntax-highlighting style or definition could not be resolved.
88 #[cfg(feature = "highlight")]
89 #[cfg_attr(docsrs, doc(cfg(feature = "highlight")))]
90 #[error("syntax highlighting error: {0}")]
91 Highlight(String),
92}
93
94#[cfg(feature = "template")]
95impl From<template::TemplateError> for Error {
96 fn from(error: template::TemplateError) -> Self {
97 Error::Template(error.to_string())
98 }
99}
100
101/// A `Result` whose error is [`Error`].
102pub type Result<T> = std::result::Result<T, Error>;
103
104/// Options controlling a [`Reader`].
105#[derive(Debug, Clone)]
106#[non_exhaustive]
107pub struct ReaderOptions {
108 /// Format extensions to enable. Strict-CommonMark readers ignore this (the empty preset).
109 pub extensions: Extensions,
110 /// Column grid a tab advances to in the formats that expand tabs. Zero leaves tabs as written.
111 pub tab_stop: usize,
112 /// When set, an open paragraph is greedy: a following line that would otherwise open a block (a
113 /// blockquote, heading, list, thematic break, fenced div, or footnote definition) is folded
114 /// into the paragraph as a lazy continuation instead. Only a blank line, a fenced code block, or
115 /// an HTML block ends the paragraph. Unset, every such line interrupts the paragraph.
116 pub greedy_paragraphs: bool,
117 /// The directory the input was named under, as written. A format whose sources refer to
118 /// companion files (included sources, imported modules, image paths) resolves them against it;
119 /// unset, such references are left exactly as written.
120 pub source_dir: Option<std::path::PathBuf>,
121}
122
123impl Default for ReaderOptions {
124 fn default() -> Self {
125 Self {
126 extensions: Extensions::default(),
127 tab_stop: 4,
128 greedy_paragraphs: false,
129 source_dir: None,
130 }
131 }
132}
133
134/// How math is presented by a format that offers a choice of renderers (the HTML family). The
135/// method decides both the inline markup inside a `span.math` and which loader a standalone document
136/// pulls in to typeset it: a MathJax (or plain) document carries the source TeX wrapped in `\(…\)` /
137/// `\[…\]`, whereas a KaTeX document carries the bare TeX, which its in-browser loader reads from the
138/// span directly.
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
140pub enum MathMethod {
141 /// No renderer: the `\(…\)` / `\[…\]` markup is left for the reader to typeset (or read as
142 /// source). The default.
143 #[default]
144 Plain,
145 /// MathJax, loaded from the given script URL. The markup keeps the `\(…\)` / `\[…\]` delimiters.
146 MathJax(String),
147 /// KaTeX, loaded from the given asset base URL (the directory holding `katex.min.js` and its
148 /// stylesheet). The span carries bare TeX without delimiters.
149 Katex(String),
150}
151
152/// How a writer supplies a table of contents.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
154pub enum TocStyle {
155 /// The contents are rendered as a nested list and placed in the `toc` template variable. The
156 /// default.
157 #[default]
158 List,
159 /// The format assembles its own contents from a directive in its template, so only a boolean
160 /// `toc` flag is exposed and no list is generated.
161 Native,
162}
163
164/// The division a format with a named sectioning hierarchy gives the document's top heading level.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum TopLevelDivision {
167 /// The format's own choice, a section wherever the choice is offered.
168 #[default]
169 Default,
170 /// Top-level headings open sections.
171 Section,
172 /// Top-level headings open chapters, the level below them sections.
173 Chapter,
174 /// Top-level headings open parts, then chapters, then sections.
175 Part,
176}
177
178impl TopLevelDivision {
179 /// The division names the outermost heading levels take, shallowest first. A level past the end
180 /// of the list opens a section.
181 #[must_use]
182 pub fn outer_divisions(self) -> &'static [&'static str] {
183 match self {
184 Self::Default | Self::Section => &[],
185 Self::Chapter => &["chapter"],
186 Self::Part => &["part", "chapter"],
187 }
188 }
189}
190
191/// Opens a line that a template must leave at column zero, however far the slot the value lands in
192/// is indented. A writer marks the lines of a construct whose own layout is its content (verbatim
193/// text, say) and the template rendering strips the mark along with the indentation before it.
194/// Document text cannot carry the character: escaping drops it in every format that marks lines.
195pub const FLUSH_LINE: char = '\u{0}';
196
197/// How a text writer lays out the lines of a paragraph.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199pub enum WrapMode {
200 /// Reflow inline content, breaking lines to keep them within the fill column. A soft line break
201 /// in the source is just inter-word space and is re-flowed like any other.
202 #[default]
203 Auto,
204 /// Never break a paragraph: each one is a single line, with soft breaks rendered as spaces. Lines
205 /// run as long as their content (only an explicit hard break starts a new line).
206 None,
207 /// Keep the source's own line breaks: a soft break stays a line break and content is not
208 /// reflowed, but lines are not wrapped to a column either.
209 Preserve,
210}
211
212/// Options for the EPUB container writer. Ignored by every other writer. The default is an empty
213/// book: no cover, no embedded fonts, the built-in stylesheet only, and chapters split at the top
214/// heading level.
215#[derive(Debug, Clone, Default)]
216#[non_exhaustive]
217pub struct EpubOptions {
218 /// A cover image as `(file name, bytes)`. Produces a dedicated cover page and marks the image
219 /// as the publication cover.
220 pub cover_image: Option<(String, Vec<u8>)>,
221
222 /// Fonts to embed verbatim, each as `(file name, bytes)`. A stylesheet refers to them by name.
223 pub fonts: Vec<(String, Vec<u8>)>,
224
225 /// User stylesheet contents, linked from every page. When any are given they replace the
226 /// built-in stylesheet entirely; several are linked in order. Empty leaves the built-in in place.
227 pub stylesheets: Vec<String>,
228
229 /// A Dublin Core metadata fragment (bare `<dc:*>` elements) merged into the package metadata.
230 pub metadata_xml: Option<String>,
231
232 /// The container directory holding all publication content. `None` uses the conventional
233 /// `EPUB`; an empty string places the content at the archive root.
234 pub subdirectory: Option<String>,
235
236 /// The heading level at which the book is split into separate chapter files. `None` splits at
237 /// the top level, so each level-one heading starts a new file.
238 pub split_level: Option<usize>,
239
240 /// Seconds since the Unix epoch fixing the publication's modification timestamp. `None` uses a
241 /// fixed epoch so output stays byte-reproducible.
242 pub source_date_epoch: Option<i64>,
243
244 /// The process locale (the `LANG` environment variable) whose language tag stands in when the
245 /// document names no `lang`. `None` falls back to `en-US`, keeping output independent of the
246 /// environment.
247 pub locale: Option<String>,
248}
249
250/// Options for the DOCX container writer. Ignored by every other writer. The default produces a
251/// self-contained document from the built-in template, with reproducible property timestamps and a
252/// language tag drawn from the document or the environment.
253#[derive(Debug, Clone, Default)]
254#[non_exhaustive]
255pub struct DocxOptions {
256 /// A reference document, as raw `.docx` bytes, whose styling parts and document template are
257 /// reused while the converted content replaces its body. `None` uses the built-in template.
258 pub reference_doc: Option<Vec<u8>>,
259
260 /// Seconds since the Unix epoch fixing the document's property timestamps. `None` uses a fixed
261 /// epoch so output stays byte-reproducible.
262 pub source_date_epoch: Option<i64>,
263
264 /// The process locale (the `LANG` environment variable) whose language tag stands in when the
265 /// document names no `lang`. `None` falls back to `en-US`, keeping output independent of the
266 /// environment.
267 pub locale: Option<String>,
268}
269
270/// Syntax-highlighting configuration for the writers that colorize code blocks (the HTML family,
271/// LaTeX, and DOCX). The default leaves code blocks unhighlighted.
272#[cfg(feature = "highlight")]
273#[cfg_attr(docsrs, doc(cfg(feature = "highlight")))]
274#[derive(Debug, Clone, Default)]
275pub struct HighlightOptions {
276 /// The tokenizer catalog. `None` leaves code blocks as a plain `<pre><code>`, with no color
277 /// spans and no line-number scaffolding.
278 pub highlighter: Option<std::sync::Arc<carta_highlight::Highlighter>>,
279
280 /// The active color theme, consulted by the writers that inline colors (LaTeX, DOCX) and to
281 /// build the HTML family's stylesheet. `None` when highlighting is off.
282 pub theme: Option<carta_highlight::Theme>,
283
284 /// Present code blocks in the target format's own listing construct rather than colorizing them.
285 /// No tokenizer runs; a format that offers a dedicated listing environment (LaTeX's `lstlisting`)
286 /// uses it, while formats whose plain form already carries the language class (the HTML family,
287 /// DOCX) render code exactly as they do with highlighting off. Ignored when a `highlighter` is set.
288 pub idiomatic: bool,
289}
290
291/// The position rendered text occupies in the layout that receives it: the column its first line
292/// starts at, and the indent its continuation lines carry. A writer that reflows to a fill column
293/// subtracts these so the text fits the place it lands in rather than the left margin.
294#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
295pub struct Slot {
296 /// Columns already consumed on the line the text opens on.
297 pub column: usize,
298 /// Columns prefixed to each line the text continues onto.
299 pub indent: usize,
300}
301
302/// Options controlling a [`Writer`].
303// Each independent output toggle is its own field; grouping them would only obscure the
304// one-option-one-field mapping a caller sets them through.
305#[allow(clippy::struct_excessive_bools)]
306#[derive(Debug, Clone, Default)]
307#[non_exhaustive]
308pub struct WriterOptions {
309 /// Format extensions to enable.
310 pub extensions: Extensions,
311
312 /// The embedded resources the document references by name but does not carry inline. A writer
313 /// that re-embeds resource bytes (a notebook re-encoding its image outputs) reads them from
314 /// here; most writers ignore it. Shared cheaply, so cloning the options does not copy the bytes.
315 pub media: Arc<MediaBag>,
316
317 /// Options for the EPUB container writer; ignored by every other writer. Shared cheaply, so
318 /// cloning the options does not copy the embedded cover, font, or stylesheet bytes.
319 pub epub: Arc<EpubOptions>,
320
321 /// Options for the DOCX container writer; ignored by every other writer.
322 pub docx: DocxOptions,
323
324 /// How paragraphs are laid out: reflowed to the fill column, never wrapped, or with the source's
325 /// own line breaks preserved.
326 pub wrap: WrapMode,
327
328 /// The fill column a wrapping writer reflows to under [`WrapMode::Auto`]. `None` uses the
329 /// writer's built-in default width.
330 pub columns: Option<usize>,
331
332 /// Where the rendered text lands in the wrapping template, so a writer that reflows to the fill
333 /// column can lay it out for the position it will occupy. Set for the body and for each metadata
334 /// value a standalone render interpolates; the default sits the text at the left margin.
335 pub slot: Slot,
336
337 /// Splice a hierarchical section number into each heading. A format that numbers headings with a
338 /// typesetting counter applies it through its template instead (see
339 /// [`Writer::numbers_sections_natively`]).
340 pub number_sections: bool,
341
342 /// The division the document's top heading level opens in a format whose sectioning hierarchy
343 /// is named. Ignored by a format whose sections are anonymous.
344 pub top_level_division: TopLevelDivision,
345
346 /// Emit a table of contents in a standalone document.
347 pub toc: bool,
348
349 /// The deepest heading level the table of contents includes. `None` uses the conventional depth
350 /// of three.
351 pub toc_depth: Option<usize>,
352
353 /// How math is presented by a format offering a choice of renderers (the HTML family).
354 pub math_method: MathMethod,
355
356 /// Syntax-highlighting configuration for code blocks; the default leaves code unhighlighted.
357 #[cfg(feature = "highlight")]
358 #[cfg_attr(docsrs, doc(cfg(feature = "highlight")))]
359 pub highlight: HighlightOptions,
360
361 /// Emit a complete document by wrapping the rendered body in the target format's template,
362 /// rather than a bare fragment.
363 #[cfg(feature = "template")]
364 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
365 pub standalone: bool,
366
367 /// Template source overriding the format's built-in default. Its presence implies standalone
368 /// output. Shared cheaply, so cloning the options does not copy the source text.
369 #[cfg(feature = "template")]
370 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
371 pub template: Option<Arc<str>>,
372
373 /// Directory used to resolve template partials (`$name()$`).
374 #[cfg(feature = "template")]
375 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
376 pub template_dir: Option<std::path::PathBuf>,
377
378 /// A shared directory of partials (`$name()$`) consulted when a partial is not found beside the
379 /// including template: the data directory's `templates/`. `None` when no data directory applies.
380 #[cfg(feature = "template")]
381 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
382 pub template_datadir: Option<std::path::PathBuf>,
383
384 /// Extension a partial (`$name()$`) inherits from the including template: the `--template`
385 /// file's own extension, so the same partial name resolves to the same kind of file whatever
386 /// the output format. An empty string means the template file had no extension (the partial is
387 /// looked up bare). Absent for a built-in default, where the format name is used instead.
388 #[cfg(feature = "template")]
389 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
390 pub template_ext: Option<String>,
391
392 /// Raw template variables, in order; a repeated key accumulates into a list. Inserted verbatim
393 /// (unescaped) at the highest precedence when building the template context.
394 #[cfg(feature = "template")]
395 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
396 pub variables: Vec<(String, String)>,
397
398 /// Metadata layered *above* the document's own (the `-M` layer): each key replaces the reader's
399 /// value for that key when the context is built.
400 #[cfg(feature = "template")]
401 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
402 pub metadata: std::collections::BTreeMap<String, carta_ast::MetaValue>,
403
404 /// Metadata layered *below* the document's own (the metadata-file layer): supplies defaults the
405 /// reader's values and `-M` override.
406 #[cfg(feature = "template")]
407 #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
408 pub metadata_defaults: std::collections::BTreeMap<String, carta_ast::MetaValue>,
409
410 /// The source name a standalone document falls back to when no `title` metadata is present: an
411 /// input file's stem, or `-` for standard input. `None` outside the command line, where there is
412 /// no source name and the fallback is empty. Consumed by the HTML family (for its `pagetitle`)
413 /// and by the container writer (for the navigation document's title).
414 #[cfg(any(feature = "template", feature = "container"))]
415 #[cfg_attr(docsrs, doc(cfg(any(feature = "template", feature = "container"))))]
416 pub source_name: Option<String>,
417}
418
419/// Parses input text in some source format into the document model.
420pub trait Reader {
421 /// Parses `input` text into a document.
422 ///
423 /// # Errors
424 /// Propagates any error from parsing the input.
425 fn read(&self, input: &str, options: &ReaderOptions) -> Result<Document>;
426
427 /// Reads `input` into a document together with the embedded resources it references. The default
428 /// carries no resources; a container format (a notebook with image outputs) overrides this to
429 /// decode those bytes into the returned [`MediaBag`], and implements [`read`](Reader::read) by
430 /// discarding the bag.
431 ///
432 /// # Errors
433 /// Propagates any error from parsing the input.
434 fn read_media(&self, input: &str, options: &ReaderOptions) -> Result<(Document, MediaBag)> {
435 Ok((self.read(input, options)?, MediaBag::new()))
436 }
437}
438
439/// Which plain-text identity variables a writer's standalone template draws on. The document's
440/// title, authors, and date are exposed as markup-free, target-escaped text for places that cannot
441/// carry markup (a web document head or a PDF document's properties). See [`Writer::meta_var_style`].
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
443pub enum MetaVarStyle {
444 /// The format exposes none of these variables.
445 #[default]
446 None,
447 /// A web document head: `pagetitle` (the title, falling back to the source name), `date-meta`
448 /// (the date), and `author-meta` (the authors, one list entry each).
449 Web,
450 /// A PDF document's properties: `title-meta` (the title) and `author-meta` (the authors joined
451 /// into one string with `; `).
452 Pdf,
453}
454
455/// Renders the document model into some target format's text.
456///
457/// The returned string carries no trailing newline; the CLI appends exactly one.
458pub trait Writer {
459 /// Renders `document` into this format's text under an output ceiling proportional to the
460 /// document's own size, so a self-multiplying construct cannot exhaust memory. Implemented by
461 /// [`render_document`](Writer::render_document).
462 ///
463 /// # Errors
464 /// Propagates any error from rendering the document, or [`Error::OutputTooLarge`] when the
465 /// rendering passes the ceiling.
466 fn write(&self, document: &Document, options: &WriterOptions) -> Result<String> {
467 budget::scope(document, || self.render_document(document, options))
468 }
469
470 /// Renders `document` into this format's text.
471 ///
472 /// # Errors
473 /// Propagates any error from rendering the document.
474 fn render_document(&self, document: &Document, options: &WriterOptions) -> Result<String>;
475
476 /// Render an inline sequence in this format, for interpolating inline metadata (a `title`, an
477 /// `author`) into a template variable. Wrapping the inlines in a [`Block::Plain`] yields them
478 /// with no paragraph chrome across formats; a writer whose `Plain` diverges overrides this.
479 ///
480 /// # Errors
481 /// Propagates any error from [`Writer::write`].
482 fn render_meta_inlines(&self, inlines: &[Inline], options: &WriterOptions) -> Result<String> {
483 let document = Document {
484 blocks: vec![Block::Plain(inlines.to_vec())],
485 ..Document::default()
486 };
487 Ok(self
488 .write(&document, options)?
489 .trim_end_matches('\n')
490 .to_string())
491 }
492
493 /// Render a block sequence in this format, for interpolating block metadata (an `abstract`
494 /// authored as Markdown blocks) into a template variable.
495 ///
496 /// # Errors
497 /// Propagates any error from [`Writer::write`].
498 fn render_meta_blocks(&self, blocks: &[Block], options: &WriterOptions) -> Result<String> {
499 let document = Document {
500 blocks: blocks.to_vec(),
501 ..Document::default()
502 };
503 Ok(self
504 .write(&document, options)?
505 .trim_end_matches('\n')
506 .to_string())
507 }
508
509 /// This format's own standalone template, or `None` when standalone output is identical to the
510 /// fragment (no wrapping document exists for the format).
511 fn default_template(&self) -> Option<&'static str> {
512 None
513 }
514
515 /// A standalone document this format assembles structurally, embedding the metadata and block
516 /// list in one value rather than wrapping a text body in a template; the data form is the
517 /// canonical example. Returned in place of template rendering. `None` (the default) when the
518 /// format wraps its body with a text template instead.
519 ///
520 /// # Errors
521 /// Propagates any error from rendering the document.
522 fn standalone_document(
523 &self,
524 document: &Document,
525 options: &WriterOptions,
526 ) -> Result<Option<String>> {
527 let _ = (document, options);
528 Ok(None)
529 }
530
531 /// Which plain-text identity variables this writer's standalone template draws on: the title,
532 /// authors, and date as markup-free text. The default is [`MetaVarStyle::None`]; an HTML-family
533 /// writer returns [`MetaVarStyle::Web`] and a LaTeX-family writer [`MetaVarStyle::Pdf`].
534 fn meta_var_style(&self) -> MetaVarStyle {
535 MetaVarStyle::None
536 }
537
538 /// Whether block-shaped metadata is flattened to its inline content when built into the template
539 /// context. A writer that places title, author, and date into single-line header fields (a man
540 /// page's `.TH` line cannot carry paragraph structure) sets this so a lone-paragraph value
541 /// contributes its inline text and any other block shape contributes nothing. The default `false`
542 /// renders block metadata as blocks.
543 fn flatten_block_metadata(&self) -> bool {
544 false
545 }
546
547 /// A title presentation the template language cannot express from individual variables: an
548 /// underlined title for reStructuredText, say, whose rule length depends on the rendered title
549 /// width. Exposed to the template as the `titleblock` variable. `None` (the default) when the
550 /// format builds its title presentation from individual variables instead.
551 ///
552 /// # Errors
553 /// Propagates any error from rendering the metadata.
554 fn title_block(&self, document: &Document, options: &WriterOptions) -> Result<Option<String>> {
555 let _ = (document, options);
556 Ok(None)
557 }
558
559 /// Whether this writer lays the document out as newline-terminated lines, so a non-empty `body`
560 /// template variable ends with a newline. Writers that build their markup as one string ending
561 /// at its final glyph (HTML, LaTeX, and the like) leave the default `false`.
562 fn body_ends_with_newline(&self) -> bool {
563 false
564 }
565
566 /// How this writer supplies a table of contents. The default renders a nested list into the
567 /// `toc` variable; a format whose template assembles its own contents from a directive overrides
568 /// to [`TocStyle::Native`].
569 fn toc_style(&self) -> TocStyle {
570 TocStyle::List
571 }
572
573 /// Whether a list-style table of contents attaches a back-reference anchor (an `id` on each
574 /// entry's link) so the entries can be linked to. The default includes them; a format that
575 /// cannot represent an inline identifier (so an attributed link would degrade to raw markup)
576 /// overrides to `false`. Honored only when [`toc_style`](Writer::toc_style) is [`TocStyle::List`].
577 fn toc_link_anchors(&self) -> bool {
578 true
579 }
580
581 /// Whether this format numbers sections with its own typesetting counter rather than carrying the
582 /// number in the heading text. The default splices a `header-section-number` span into each
583 /// heading; a format with a native counter (the typesetting formats) overrides to `true` and is
584 /// driven by a `numbersections` template flag instead.
585 fn numbers_sections_natively(&self) -> bool {
586 false
587 }
588
589 /// Whether this writer carries section numbers in the heading text, so the number is spliced into
590 /// each heading before rendering (and contents entries inherit it). The default leaves headings
591 /// untouched; a format that renders the number inline (HTML) overrides to `true`. A format with a
592 /// native counter relies on [`numbers_sections_natively`](Writer::numbers_sections_natively)
593 /// instead and leaves this `false`.
594 fn numbers_sections_in_body(&self) -> bool {
595 false
596 }
597}
598
599/// Parses input bytes in some source format into the document model. The byte-shaped counterpart of
600/// [`Reader`], for formats whose wire form is not text (zip containers and the like).
601pub trait BytesReader {
602 /// Parses `input` bytes into a document.
603 ///
604 /// # Errors
605 /// Propagates any error from parsing the input.
606 fn read(&self, input: &[u8], options: &ReaderOptions) -> Result<Document>;
607
608 /// Reads `input` into a document together with the embedded resources it references. The
609 /// byte-shaped counterpart of [`Reader::read_media`]; the default carries no resources.
610 ///
611 /// # Errors
612 /// Propagates any error from parsing the input.
613 fn read_media(&self, input: &[u8], options: &ReaderOptions) -> Result<(Document, MediaBag)> {
614 Ok((self.read(input, options)?, MediaBag::new()))
615 }
616}
617
618/// Renders the document model into some target format's bytes. The byte-shaped counterpart of
619/// [`Writer`], for formats whose output is not text (zip containers and the like).
620///
621/// This trait carries no decoration hooks (templates, table of contents, metadata rendering): a
622/// container writer produces a complete document by construction.
623pub trait BytesWriter {
624 /// Renders `document` into this format's bytes under an output ceiling proportional to the
625 /// document's own size, the byte-shaped counterpart of [`Writer::write`]. Implemented by
626 /// [`render_document`](BytesWriter::render_document).
627 ///
628 /// # Errors
629 /// Propagates any error from rendering the document, or [`Error::OutputTooLarge`] when the
630 /// rendering passes the ceiling.
631 fn write(&self, document: &Document, options: &WriterOptions) -> Result<Vec<u8>> {
632 budget::scope(document, || self.render_document(document, options))
633 }
634
635 /// Renders `document` into this format's bytes.
636 ///
637 /// # Errors
638 /// Propagates any error from rendering the document.
639 fn render_document(&self, document: &Document, options: &WriterOptions) -> Result<Vec<u8>>;
640}
641
642/// The output of a conversion: text from a text writer, bytes from a byte-shaped writer.
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub enum Output {
645 /// Text produced by a text-shaped writer.
646 Text(String),
647 /// Bytes produced by a byte-shaped writer.
648 Bytes(Vec<u8>),
649}
650
651/// A resolved reader, either text-shaped ([`Reader`]) or byte-shaped ([`BytesReader`]).
652pub enum AnyReader {
653 /// A text-shaped reader; input is decoded as UTF-8 before parsing.
654 Text(Box<dyn Reader>),
655 /// A byte-shaped reader; input is parsed from raw bytes.
656 Bytes(Box<dyn BytesReader>),
657}
658
659impl fmt::Debug for AnyReader {
660 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
661 let variant = match self {
662 AnyReader::Text(_) => "Text",
663 AnyReader::Bytes(_) => "Bytes",
664 };
665 f.debug_tuple(variant).finish()
666 }
667}
668
669impl AnyReader {
670 /// Reads `input` into a document. A text reader decodes the bytes as UTF-8 first; a byte reader
671 /// takes the raw slice.
672 ///
673 /// # Errors
674 /// [`Error::InvalidUtf8`] if a text reader is handed input that is not valid UTF-8, plus any error
675 /// the underlying reader returns.
676 pub fn read(&self, input: &[u8], options: &ReaderOptions) -> Result<Document> {
677 match self {
678 AnyReader::Text(reader) => reader.read(std::str::from_utf8(input)?, options),
679 AnyReader::Bytes(reader) => reader.read(input, options),
680 }
681 }
682
683 /// Reads `input` into a document together with the embedded resources it references. A text
684 /// reader decodes the bytes as UTF-8 first; a byte reader takes the raw slice. A reader that
685 /// carries no resources returns an empty [`MediaBag`].
686 ///
687 /// # Errors
688 /// [`Error::InvalidUtf8`] if a text reader is handed input that is not valid UTF-8, plus any
689 /// error the underlying reader returns.
690 pub fn read_media(
691 &self,
692 input: &[u8],
693 options: &ReaderOptions,
694 ) -> Result<(Document, MediaBag)> {
695 match self {
696 AnyReader::Text(reader) => reader.read_media(std::str::from_utf8(input)?, options),
697 AnyReader::Bytes(reader) => reader.read_media(input, options),
698 }
699 }
700}
701
702/// A resolved writer, either text-shaped ([`Writer`]) or byte-shaped ([`BytesWriter`]).
703pub enum AnyWriter {
704 /// A text-shaped writer; rendering produces a string.
705 Text(Box<dyn Writer>),
706 /// A byte-shaped writer; rendering produces raw bytes.
707 Bytes(Box<dyn BytesWriter>),
708}
709
710impl fmt::Debug for AnyWriter {
711 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
712 let variant = match self {
713 AnyWriter::Text(_) => "Text",
714 AnyWriter::Bytes(_) => "Bytes",
715 };
716 f.debug_tuple(variant).finish()
717 }
718}
719
720impl AnyWriter {
721 /// This format's own standalone template, or `None` when standalone output is identical to the
722 /// fragment. A byte-shaped writer never has one.
723 #[must_use]
724 pub fn default_template(&self) -> Option<&'static str> {
725 match self {
726 AnyWriter::Text(writer) => writer.default_template(),
727 AnyWriter::Bytes(_) => None,
728 }
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::{
735 AnyReader, AnyWriter, BytesReader, BytesWriter, Error, Reader, ReaderOptions, Result,
736 WriterOptions,
737 };
738 use carta_ast::Document;
739
740 struct FixedBytesWriter;
741 impl BytesWriter for FixedBytesWriter {
742 fn render_document(
743 &self,
744 _document: &Document,
745 _options: &WriterOptions,
746 ) -> Result<Vec<u8>> {
747 Ok(vec![0x00, 0xff, 0x9f])
748 }
749 }
750
751 struct RawBytesReader;
752 impl BytesReader for RawBytesReader {
753 fn read(&self, input: &[u8], _options: &ReaderOptions) -> Result<Document> {
754 assert_eq!(input, &[0xff, 0xfe]);
755 Ok(Document::default())
756 }
757 }
758
759 struct EmptyTextReader;
760 impl Reader for EmptyTextReader {
761 fn read(&self, _input: &str, _options: &ReaderOptions) -> Result<Document> {
762 Ok(Document::default())
763 }
764 }
765
766 #[test]
767 fn bytes_writer_round_trips_bytes() {
768 let writer = AnyWriter::Bytes(Box::new(FixedBytesWriter));
769 assert!(writer.default_template().is_none());
770 let AnyWriter::Bytes(inner) = &writer else {
771 panic!("expected a byte writer");
772 };
773 let output = inner
774 .write(&Document::default(), &WriterOptions::default())
775 .unwrap();
776 assert_eq!(output, vec![0x00, 0xff, 0x9f]);
777 }
778
779 #[test]
780 fn text_reader_rejects_invalid_utf8() {
781 let reader = AnyReader::Text(Box::new(EmptyTextReader));
782 let error = reader
783 .read(&[0xff, 0xfe], &ReaderOptions::default())
784 .unwrap_err();
785 assert!(matches!(error, Error::InvalidUtf8(_)), "{error:?}");
786 }
787
788 #[test]
789 fn bytes_reader_accepts_invalid_utf8() {
790 let reader = AnyReader::Bytes(Box::new(RawBytesReader));
791 assert!(
792 reader
793 .read(&[0xff, 0xfe], &ReaderOptions::default())
794 .is_ok()
795 );
796 }
797
798 #[test]
799 fn default_read_media_carries_no_resources() {
800 let reader = AnyReader::Text(Box::new(EmptyTextReader));
801 let (_, media) = reader
802 .read_media(b"anything", &ReaderOptions::default())
803 .expect("read succeeds");
804 assert!(media.is_empty());
805 }
806}