1use 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
27pub const PAGE_EXTENSION: &str = "png";
29
30#[derive(Debug, Clone)]
32pub struct Document {
33 doc: Option<Box<PagedDocument>>,
34 buffers: EcoVec<Pixmap>,
35}
36
37impl Document {
38 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 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 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 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 #[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 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 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 buffers: buffers.into_values().collect(),
153 })
154 }
155
156 #[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 pub fn doc(&self) -> Option<&PagedDocument> {
196 self.doc.as_deref()
197 }
198
199 pub fn buffers(&self) -> &[Pixmap] {
201 &self.buffers
202 }
203}
204
205impl Document {
206 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#[derive(Debug, Error)]
239pub enum LoadError {
240 #[error("one or more pages were missing, found: {0:?}")]
243 MissingPages(BTreeSet<usize>),
244
245 #[error("a page could not be decoded")]
247 Page(#[from] png::DecodingError),
248
249 #[error("an io error occurred")]
251 Io(#[from] io::Error),
252}
253
254#[derive(Debug, Error)]
256pub enum SaveError {
257 #[error("a page could not be optimized")]
259 Optimize(#[from] oxipng::PngError),
260
261 #[error("a page could not be encoded")]
263 Page(#[from] png::EncodingError),
264
265 #[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}