1use 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
25pub const PAGE_EXTENSION: &str = "png";
27
28#[derive(Debug, Clone)]
30pub struct Document {
31 doc: Option<PagedDocument>,
32 buffers: EcoVec<Pixmap>,
33}
34
35impl Document {
36 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 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 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 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 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 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 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 buffers: buffers.into_values().collect(),
149 })
150 }
151
152 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 pub fn doc(&self) -> Option<&PagedDocument> {
189 self.doc.as_ref()
190 }
191
192 pub fn buffers(&self) -> &[Pixmap] {
194 &self.buffers
195 }
196}
197
198impl Document {
199 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#[derive(Debug, Error)]
232pub enum LoadError {
233 #[error("one or more pages were missing, found: {0:?}")]
236 MissingPages(BTreeSet<usize>),
237
238 #[error("a page could not be decoded")]
240 Page(#[from] png::DecodingError),
241
242 #[error("an io error occurred")]
244 Io(#[from] io::Error),
245}
246
247#[derive(Debug, Error)]
249pub enum SaveError {
250 #[error("a page could not be optimized")]
252 Optimize(#[from] oxipng::PngError),
253
254 #[error("a page could not be encoded")]
256 Page(#[from] png::EncodingError),
257
258 #[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}