Skip to main content

typst_pack/
compile.rs

1//! Compiling a pack into output documents.
2
3use std::num::NonZeroUsize;
4
5use ecow::EcoVec;
6use typst::World;
7use typst::diag::{SourceDiagnostic, Warned};
8use typst_html::HtmlDocument;
9use typst_layout::PagedDocument;
10use typst_pdf::{PdfOptions, PdfStandards, Timestamp};
11
12/// The output formats a pack can be compiled to.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15    Pdf,
16    Png,
17    Svg,
18    /// HTML export is experimental in Typst; compiling to it requires a world
19    /// whose library has [`Feature::Html`](typst::Feature::Html) enabled
20    /// (see [`PackWorldBuilder::feature`](crate::PackWorldBuilder::feature)),
21    /// otherwise compilation errors.
22    Html,
23}
24
25impl OutputFormat {
26    /// The conventional file extension for this format.
27    pub fn extension(self) -> &'static str {
28        match self {
29            Self::Pdf => "pdf",
30            Self::Png => "png",
31            Self::Svg => "svg",
32            Self::Html => "html",
33        }
34    }
35}
36
37/// Options for [`compile`].
38#[derive(Debug, Clone, Default)]
39pub struct CompileOptions {
40    /// Which pages to export. All pages if empty.
41    ///
42    /// Ranges are one-indexed and inclusive, with open ends allowed, matching
43    /// `typst compile --pages`.
44    pub pages: Vec<PageRange>,
45    /// Pixels per inch for PNG output. Defaults to 144.
46    pub ppi: Option<f32>,
47    /// PDF standards to enforce.
48    pub pdf_standards: PdfStandards,
49    /// The document creation datetime recorded in PDF metadata. When `None`,
50    /// the date is derived from the world's `today`.
51    pub creation_timestamp: Option<Timestamp>,
52}
53
54/// A one-indexed, inclusive page range with optional open ends.
55pub type PageRange = std::ops::RangeInclusive<Option<NonZeroUsize>>;
56
57/// Parses a `--pages`-style page selection like `1,3-5,9-`.
58pub fn parse_pages(text: &str) -> Result<Vec<PageRange>, String> {
59    text.split(',')
60        .map(|part| {
61            let part = part.trim();
62            let parse = |s: &str| -> Result<Option<NonZeroUsize>, String> {
63                if s.is_empty() {
64                    return Ok(None);
65                }
66                s.parse::<NonZeroUsize>()
67                    .map(Some)
68                    .map_err(|_| format!("invalid page number `{s}`"))
69            };
70            match part.split_once('-') {
71                Some((start, end)) => Ok(parse(start)?..=parse(end)?),
72                None => {
73                    let page = parse(part)?.ok_or_else(|| "empty page range".to_owned())?;
74                    Ok(Some(page)..=Some(page))
75                }
76            }
77        })
78        .collect()
79}
80
81/// The result of compiling a pack.
82#[derive(Debug, Clone)]
83pub struct CompileOutput {
84    /// The produced format.
85    pub format: OutputFormat,
86    /// The output documents: exactly one buffer for PDF, one buffer per
87    /// exported page for PNG and SVG.
88    pub outputs: Vec<Vec<u8>>,
89    /// Warnings emitted during compilation.
90    pub warnings: EcoVec<SourceDiagnostic>,
91}
92
93/// A failed compilation.
94#[derive(Debug, thiserror::Error)]
95pub enum CompileError {
96    /// Compilation or export produced errors; warnings are included for
97    /// complete reporting.
98    #[error("compilation failed with {} error(s)", errors.len())]
99    Diagnostics {
100        errors: EcoVec<SourceDiagnostic>,
101        warnings: EcoVec<SourceDiagnostic>,
102    },
103    #[error("PNG encoding failed: {0}")]
104    PngEncoding(String),
105}
106
107/// Compiles the world's document and exports it in the requested format.
108///
109/// This works with any [`World`], but is intended for
110/// [`PackWorld`](crate::PackWorld).
111pub fn compile(
112    world: &dyn World,
113    format: OutputFormat,
114    options: &CompileOptions,
115) -> Result<CompileOutput, CompileError> {
116    if format == OutputFormat::Html {
117        let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
118        let document = output.map_err(|errors| CompileError::Diagnostics {
119            errors,
120            warnings: warnings.clone(),
121        })?;
122        let html =
123            typst_html::html(&document, &typst_html::HtmlOptions::default()).map_err(|errors| {
124                CompileError::Diagnostics {
125                    errors,
126                    warnings: warnings.clone(),
127                }
128            })?;
129        return Ok(CompileOutput {
130            format,
131            outputs: vec![html.into_bytes()],
132            warnings,
133        });
134    }
135
136    let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
137    let document = output.map_err(|errors| CompileError::Diagnostics {
138        errors,
139        warnings: warnings.clone(),
140    })?;
141
142    let outputs = match format {
143        OutputFormat::Pdf => {
144            let timestamp = options
145                .creation_timestamp
146                .or_else(|| world.today(None).map(Timestamp::new_utc));
147            let pdf_options = PdfOptions {
148                timestamp,
149                page_ranges: page_ranges(options),
150                standards: options.pdf_standards.clone(),
151                ..Default::default()
152            };
153            let pdf = typst_pdf::pdf(&document, &pdf_options).map_err(|errors| {
154                CompileError::Diagnostics {
155                    errors,
156                    warnings: warnings.clone(),
157                }
158            })?;
159            vec![pdf]
160        }
161        OutputFormat::Png => {
162            let ppi = options.ppi.unwrap_or(144.0);
163            let render_options = typst_render::RenderOptions {
164                pixel_per_pt: (f64::from(ppi) / 72.0).into(),
165                ..Default::default()
166            };
167            selected_pages(&document, options)
168                .map(|page| {
169                    typst_render::render(page, &render_options)
170                        .encode_png()
171                        .map_err(|err| CompileError::PngEncoding(err.to_string()))
172                })
173                .collect::<Result<Vec<_>, _>>()?
174        }
175        OutputFormat::Svg => {
176            let svg_options = typst_svg::SvgOptions::default();
177            selected_pages(&document, options)
178                .map(|page| typst_svg::svg(page, &svg_options).into_bytes())
179                .collect()
180        }
181        OutputFormat::Html => unreachable!("handled above"),
182    };
183
184    Ok(CompileOutput {
185        format,
186        outputs,
187        warnings,
188    })
189}
190
191fn page_ranges(options: &CompileOptions) -> Option<typst::layout::PageRanges> {
192    (!options.pages.is_empty()).then(|| typst::layout::PageRanges::new(options.pages.clone()))
193}
194
195fn selected_pages<'a>(
196    document: &'a PagedDocument,
197    options: &'a CompileOptions,
198) -> impl Iterator<Item = &'a typst_layout::Page> {
199    let ranges = page_ranges(options);
200    document
201        .pages()
202        .iter()
203        .enumerate()
204        .filter(move |(index, _)| {
205            ranges.as_ref().is_none_or(|ranges| {
206                NonZeroUsize::new(index + 1).is_some_and(|number| ranges.includes_page(number))
207            })
208        })
209        .map(|(_, page)| page)
210}