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<Box<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<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 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 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 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 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 buffers: buffers.into_values().collect(),
151 })
152 }
153
154 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 pub fn doc(&self) -> Option<&PagedDocument> {
191 self.doc.as_deref()
192 }
193
194 pub fn buffers(&self) -> &[Pixmap] {
196 &self.buffers
197 }
198}
199
200impl Document {
201 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#[derive(Debug, Error)]
234pub enum LoadError {
235 #[error("one or more pages were missing, found: {0:?}")]
238 MissingPages(BTreeSet<usize>),
239
240 #[error("a page could not be decoded")]
242 Page(#[from] png::DecodingError),
243
244 #[error("an io error occurred")]
246 Io(#[from] io::Error),
247}
248
249#[derive(Debug, Error)]
251pub enum SaveError {
252 #[error("a page could not be optimized")]
254 Optimize(#[from] oxipng::PngError),
255
256 #[error("a page could not be encoded")]
258 Page(#[from] png::EncodingError),
259
260 #[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}