tytanic_core/doc/
mod.rs

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