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