Skip to main content

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}