tytanic_core/doc/
mod.rs

1//! On-disk management of reference and test documents.
2//!
3//! These documents are currently stored as individual pages in the PNG format.
4
5use std::collections::BTreeMap;
6use std::collections::BTreeSet;
7use std::fs;
8use std::io;
9use std::iter;
10use std::path::Path;
11
12use compile::Warnings;
13use ecow::EcoVec;
14use thiserror::Error;
15use tiny_skia::Pixmap;
16use typst::World;
17use typst::diag::Warned;
18use typst::layout::PagedDocument;
19
20use self::compare::Strategy;
21use self::render::Origin;
22
23pub mod compare;
24pub mod compile;
25pub mod render;
26
27/// The extension used in the page storage, each page is stored separately with it.
28pub const PAGE_EXTENSION: &str = "png";
29
30/// A document that was rendered from an in-memory compilation, or loaded from disk.
31#[derive(Debug, Clone)]
32pub struct Document {
33    doc: Option<Box<PagedDocument>>,
34    buffers: EcoVec<Pixmap>,
35}
36
37impl Document {
38    /// Creates a new document from the given buffers.
39    pub fn new<I: IntoIterator<Item = Pixmap>>(buffers: I) -> Self {
40        Self {
41            doc: None,
42            buffers: buffers.into_iter().collect(),
43        }
44    }
45
46    /// Compiles and renders a new document from the given source.
47    pub fn compile(
48        world: &dyn World,
49        pixel_per_pt: f32,
50        warnings: Warnings,
51    ) -> Warned<Result<Self, compile::Error>> {
52        let Warned { output, warnings } = compile::compile(world, warnings);
53
54        Warned {
55            output: output.map(|doc| Self::render(doc, pixel_per_pt)),
56            warnings,
57        }
58    }
59
60    /// Creates a new rendered document from a compiled one.
61    pub fn render<D: Into<Box<PagedDocument>>>(doc: D, pixel_per_pt: f32) -> Self {
62        let doc = doc.into();
63
64        let buffers = doc
65            .pages
66            .iter()
67            .map(|page| typst_render::render(page, pixel_per_pt))
68            .collect();
69
70        Self {
71            doc: Some(doc),
72            buffers,
73        }
74    }
75
76    /// Renders a diff from the given documents pixel buffers, the resulting new
77    /// document will have no inner document set because it was created only
78    /// from pixel buffers.
79    ///
80    /// Diff images are created pair-wise in order using [`render::page_diff`].
81    pub fn render_diff(base: &Self, change: &Self, origin: Origin) -> Self {
82        let buffers = iter::zip(&base.buffers, &change.buffers)
83            .map(|(base, change)| render::page_diff(base, change, origin))
84            .collect();
85
86        Self { doc: None, buffers }
87    }
88
89    /// Collects the reference document in the given directory.
90    #[tracing::instrument(skip_all, fields(dir = ?dir.as_ref()))]
91    pub fn load<P: AsRef<Path>>(dir: P) -> Result<Self, LoadError> {
92        let mut buffers = BTreeMap::new();
93
94        for entry in fs::read_dir(dir)? {
95            let entry = entry?;
96            let path = entry.path();
97
98            if !entry.file_type()?.is_file() {
99                tracing::trace!(entry = ?path, "ignoring non-file entry in reference directory");
100                continue;
101            }
102
103            if path.extension().is_none()
104                || path.extension().is_some_and(|ext| ext != PAGE_EXTENSION)
105            {
106                tracing::trace!(entry = ?path, "ignoring non-PNG entry in reference directory");
107                continue;
108            }
109
110            let Some(page) = path
111                .file_stem()
112                .and_then(|s| s.to_str())
113                .and_then(|s| s.parse().ok())
114                .filter(|&num| num != 0)
115            else {
116                tracing::trace!(
117                    entry = ?path,
118                    "ignoring non-numeric or invalid filename in reference directory",
119                );
120                continue;
121            };
122
123            buffers.insert(page, Pixmap::load_png(path)?);
124        }
125
126        // Check we got pages starting at 1.
127        match buffers.first_key_value() {
128            Some((min, _)) if *min != 1 => {
129                return Err(LoadError::MissingPages(buffers.into_keys().collect()));
130            }
131            Some(_) => {}
132            None => {
133                return Err(LoadError::MissingPages(buffers.into_keys().collect()));
134            }
135        }
136
137        // Check we got pages ending in the page count.
138        match buffers.last_key_value() {
139            Some((max, _)) if *max != buffers.len() => {
140                return Err(LoadError::MissingPages(buffers.into_keys().collect()));
141            }
142            Some(_) => {}
143            None => {
144                return Err(LoadError::MissingPages(buffers.into_keys().collect()));
145            }
146        }
147
148        Ok(Self {
149            doc: None,
150            // NOTE(tinger): the pages are ordered by key and must not have any
151            // page keys missing
152            buffers: buffers.into_values().collect(),
153        })
154    }
155
156    /// Saves a single page within the given directory with the given 1-based page
157    /// number.
158    ///
159    /// # Panics
160    /// Panics if `num == 0`.
161    #[tracing::instrument(skip_all, fields(dir = ?dir.as_ref()))]
162    pub fn save<P: AsRef<Path>>(
163        &self,
164        dir: P,
165        optimize_options: Option<&oxipng::Options>,
166    ) -> Result<(), SaveError> {
167        tracing::trace!(?optimize_options, "using optimize options");
168
169        for (num, page) in self
170            .buffers
171            .iter()
172            .enumerate()
173            .map(|(idx, page)| (idx + 1, page))
174        {
175            let path = dir
176                .as_ref()
177                .join(num.to_string())
178                .with_extension(PAGE_EXTENSION);
179
180            if let Some(options) = optimize_options {
181                let buffer = page.encode_png()?;
182                let optimized = oxipng::optimize_from_memory(&buffer, options)?;
183                fs::write(path, optimized)?;
184            } else {
185                page.save_png(path)?;
186            }
187        }
188
189        Ok(())
190    }
191}
192
193impl Document {
194    /// The inner document if this was created from an in-memory compilation.
195    pub fn doc(&self) -> Option<&PagedDocument> {
196        self.doc.as_deref()
197    }
198
199    /// The pixel buffers of the rendered pages in this document.
200    pub fn buffers(&self) -> &[Pixmap] {
201        &self.buffers
202    }
203}
204
205impl Document {
206    /// Compares two documents using the given strategy.
207    ///
208    /// Comparisons are created pair-wise in order using [`compare::page`].
209    pub fn compare(
210        outputs: &Self,
211        references: &Self,
212        strategy: Strategy,
213    ) -> Result<(), compare::Error> {
214        let output_len = outputs.buffers.len();
215        let reference_len = references.buffers.len();
216
217        let mut page_errors = Vec::with_capacity(Ord::min(output_len, reference_len));
218
219        for (idx, (a, b)) in iter::zip(&outputs.buffers, &references.buffers).enumerate() {
220            if let Err(err) = compare::page(a, b, strategy) {
221                page_errors.push((idx, err));
222            }
223        }
224
225        if !page_errors.is_empty() || output_len != reference_len {
226            page_errors.shrink_to_fit();
227            return Err(compare::Error {
228                output: output_len,
229                reference: reference_len,
230                pages: page_errors,
231            });
232        }
233
234        Ok(())
235    }
236}
237/// Returned by [`Document::load`].
238#[derive(Debug, Error)]
239pub enum LoadError {
240    /// One or more pages were missing, contains the physical page numbers which
241    /// were found.
242    #[error("one or more pages were missing, found: {0:?}")]
243    MissingPages(BTreeSet<usize>),
244
245    /// A page could not be decoded.
246    #[error("a page could not be decoded")]
247    Page(#[from] png::DecodingError),
248
249    /// An io error occurred.
250    #[error("an io error occurred")]
251    Io(#[from] io::Error),
252}
253
254/// Returned by [`Document::save`].
255#[derive(Debug, Error)]
256pub enum SaveError {
257    /// A page could not be optimized.
258    #[error("a page could not be optimized")]
259    Optimize(#[from] oxipng::PngError),
260
261    /// A page could not be encoded.
262    #[error("a page could not be encoded")]
263    Page(#[from] png::EncodingError),
264
265    /// An IO error occurred.
266    #[error("an io error occurred")]
267    Io(#[from] io::Error),
268}
269
270#[cfg(test)]
271mod tests {
272    use ecow::eco_vec;
273    use tytanic_utils::fs::TempTestEnv;
274
275    use super::*;
276
277    #[test]
278    fn test_document_save() {
279        let doc = Document {
280            doc: None,
281            buffers: eco_vec![Pixmap::new(10, 10).unwrap(); 3],
282        };
283
284        TempTestEnv::run(
285            |root| root,
286            |root| {
287                doc.save(root, None).unwrap();
288            },
289            |root| {
290                root.expect_file_content("1.png", doc.buffers[0].encode_png().unwrap())
291                    .expect_file_content("2.png", doc.buffers[1].encode_png().unwrap())
292                    .expect_file_content("3.png", doc.buffers[2].encode_png().unwrap())
293            },
294        );
295    }
296
297    #[test]
298    fn test_document_load() {
299        let buffers = eco_vec![Pixmap::new(10, 10).unwrap(); 3];
300
301        TempTestEnv::run_no_check(
302            |root| {
303                root.setup_file("1.png", buffers[0].encode_png().unwrap())
304                    .setup_file("2.png", buffers[1].encode_png().unwrap())
305                    .setup_file("3.png", buffers[2].encode_png().unwrap())
306            },
307            |root| {
308                let doc = Document::load(root).unwrap();
309
310                assert_eq!(doc.buffers[0], buffers[0]);
311                assert_eq!(doc.buffers[1], buffers[1]);
312                assert_eq!(doc.buffers[2], buffers[2]);
313            },
314        );
315    }
316}