tikz-gallery-generator

Custum build of stapix for tikz.pablopie.xyz

main.rs (20805B)

  1 use crossterm::style::Stylize;
  2 use image::{DynamicImage, io::Reader as ImageReader};
  3 use std::{
  4     cmp::min,
  5     env,
  6     fmt::{self, Display},
  7     fs::{self, File},
  8     io::{self, Write},
  9     path::PathBuf,
 10     process::{ExitCode, Command},
 11     sync::mpsc,
 12     os::unix,
 13 };
 14 use gallery_entry::{GalleryEntry, FileFormat, LicenseType};
 15 use threadpool::ThreadPool;
 16 
 17 #[macro_use]
 18 mod log;
 19 mod gallery_entry;
 20 
 21 /// A wrapper for displaying the path for the thumbnail of a given path
 22 pub struct ThumbPath<'a>(pub &'a GalleryEntry);
 23 
 24 /// A wrapper for HTML-escaped strings
 25 pub struct Escaped<'a>(pub &'a str);
 26 
 27 /// A wrapper to display lists of command line arguments
 28 struct ArgList<'a>(pub &'a [String]);
 29 
 30 #[derive(Clone, Copy, PartialEq, Eq)]
 31 enum RenderResult {
 32     Skipped,
 33     Success,
 34     Failure,
 35 }
 36 
 37 const FULL_BUILD_OPT: &str = "--full-build";
 38 
 39 const TARGET_PATH:  &str = "./site";
 40 const PAGES_PATH:   &str = "figures";
 41 const IMAGES_PATH:  &str = "assets/images";
 42 const THUMBS_PATH:  &str = "assets/thumbs";
 43 const FAVICON_PATH: &str = "assets/favicon.svg";
 44 const FONTS_PATH:   &str = "assets/fonts";
 45 const STYLES_PATH:  &str = "assets/css/styles.css";
 46 
 47 const PAGE_TITLE: &str = "TikZ Gallery";
 48 const AUTHOR:     &str = "Pablo";
 49 const LICENSE:    &str = "GPLv3";
 50 
 51 /// HTML to be inserted in the beginning/end of index.html during generation
 52 const INTRO_MSG: &str = include_str!("intro.html");
 53 const OUTRO_MSG: &str = include_str!("outro.html");
 54 
 55 /// WebP image quality
 56 const WEBP_IMAGE_QUALITY: f32 = 90.0;
 57 /// Target height of the thumbnails
 58 const THUMB_HEIGHT: u32 = 500;
 59 
 60 fn main() -> ExitCode {
 61     infoln!("Running {package} version {version}",
 62            package = env!("CARGO_PKG_NAME"),
 63            version = env!("CARGO_PKG_VERSION"));
 64 
 65     let args: Vec<String> = env::args().collect();
 66 
 67     let (program, config, full_build) = match &args[..] {
 68         [program, config] => (program, config, false),
 69         [program, config, opt] if opt == FULL_BUILD_OPT => {
 70             (program, config, true)
 71         }
 72         [program, _config, ..] => {
 73             errorln!("Unknown arguments: {}", ArgList(&args[2..]));
 74             usage!(program);
 75             return ExitCode::FAILURE;
 76         }
 77         [program] => {
 78             errorln!("Expected 1 command line argument, found none");
 79             usage!(program);
 80             return ExitCode::FAILURE;
 81         }
 82         [] => unreachable!("args always contains at least the input program"),
 83     };
 84 
 85     let f = File::open(config);
 86     match f.map(serde_yaml::from_reader::<_, Vec<GalleryEntry>>) {
 87         // Error opening the config file
 88         Err(err) => {
 89             errorln!("Couldn't open {config:?}: {err}");
 90             usage!(program);
 91             ExitCode::FAILURE
 92         }
 93         // Error parsing the config file
 94         Ok(Err(err)) => {
 95             errorln!("Couldn't parse {config:?}: {err}");
 96             usage_config!();
 97             ExitCode::FAILURE
 98         }
 99         Ok(Ok(pics)) => render_gallery(pics, full_build),
100     }
101 }
102 
103 /// Coordinates the rendering of all the pages and file conversions
104 fn render_gallery(pics: Vec<GalleryEntry>, full_build: bool) -> ExitCode {
105     info!("Copying image files to the target directory...");
106 
107     for pic in &pics {
108         let mut target_path = PathBuf::from(TARGET_PATH);
109         target_path.push(IMAGES_PATH);
110         target_path.push(&pic.file_name);
111 
112         if let Err(err) = fs::copy(&pic.path, &target_path) {
113             errorln!(
114                 "Couldn't copy file {src:?} to {target:?}: {err}",
115                 src = pic.path,
116                 target = target_path,
117            );
118             return ExitCode::FAILURE;
119         }
120     }
121 
122     info_done!();
123 
124     // ========================================================================
125     for pic in &pics {
126         if pic.alt.is_empty() {
127             warnln!(
128                 "Empty text alternative was specified for the file {name:?}",
129                 name = pic.file_name
130             );
131         }
132     }
133 
134     // ========================================================================
135     let num_threads = min(num_cpus::get() + 1, pics.len());
136     let rendering_pool = ThreadPool::with_name(
137         String::from("thumbnails renderer"),
138         num_threads
139     );
140     let (sender, reciever) = mpsc::channel();
141 
142     infoln!( "Started generating thumbnails (using {num_threads} threads)");
143 
144     for pic in &pics {
145         let sender = sender.clone();
146         let pic = pic.clone();
147         rendering_pool.execute(move || {
148             sender.send(render_thumbnail(pic, full_build))
149                 .expect("channel should still be alive awaiting for the completion of this task");
150         });
151     }
152 
153     for _ in 0..pics.len() {
154         match reciever.recv() {
155             Ok(RenderResult::Failure) => return ExitCode::FAILURE,
156             Ok(RenderResult::Success | RenderResult::Skipped)  => {}
157             Err(_)    => {
158                 // Propagate the panic to the main thread: reciever.recv should
159                 // only fail if some of the rendering threads panicked
160                 panic!("rendering thread panicked!");
161             }
162         }
163     }
164 
165     infoln!("Done generating thumbnails!");
166 
167     // ========================================================================
168     info!("Rendering index.html...");
169     if render_index(&pics).is_err() {
170         return ExitCode::FAILURE;
171     }
172     info_done!();
173 
174     for pic in pics {
175         info!("Rendering HTML page for {name:?}...", name = pic.file_name);
176         match render_pic_page(&pic, full_build) {
177             RenderResult::Success => info_done!(),
178             RenderResult::Skipped => {
179                 info_done!("Skipped! (use {FULL_BUILD_OPT} to overwrite)");
180             }
181             RenderResult::Failure => return ExitCode::FAILURE,
182         }
183     }
184 
185     ExitCode::SUCCESS
186 }
187 
188 fn render_index(pics: &Vec<GalleryEntry>) -> io::Result<()> {
189     let mut path = PathBuf::from(TARGET_PATH);
190     path.push("index.html");
191 
192     let mut f = File::create(path)?;
193 
194     writeln!(f, "<!DOCTYPE html>")?;
195     write_license(&mut f)?;
196     writeln!(f, "<html lang=\"en\">")?;
197     writeln!(f, "<head>")?;
198     writeln!(f, "<title>{PAGE_TITLE}</title>")?;
199     write_head(&mut f)?;
200 
201     // Preload the first 2 pictures in the gallery
202     for pic in pics.iter().take(2) {
203         writeln!(
204             f,
205             "<link rel=\"preload\" as=\"image\" href=\"{path}\">",
206             path = ThumbPath(pic),
207         )?;
208     }
209 
210     writeln!(f, "</head>")?;
211 
212     writeln!(f, "<body>")?;
213 
214     writeln!(f, "<main>")?;
215     writeln!(f, "{}", INTRO_MSG)?;
216 
217     writeln!(f, "<div id=\"gallery\" role=\"feed\">")?;
218 
219     for pic in pics {
220         writeln!(f, "<article class=\"picture-container\">")?;
221         writeln!(
222             f,
223             "<a aria-label=\"{name}\" href=\"/{PAGES_PATH}/{name}.html\">",
224             name = Escaped(&pic.file_name)
225         )?;
226         writeln!(
227             f,
228             "<img alt=\"{alt}\" src=\"{path}\">",
229             alt = Escaped(&pic.alt),
230             path = ThumbPath(pic),
231         )?;
232         writeln!(f, "</a>\n</article>")?;
233     }
234 
235     writeln!(f, "</div>")?;
236 
237     writeln!(f, "{}", OUTRO_MSG)?;
238     writeln!(f, "</main>")?;
239 
240     writeln!(f, "<footer>")?;
241     writeln!(
242         f,
243         "made with 💚 by <a role=\"author\" href=\"https://pablopie.xyz\">@pablo</a>"
244     )?;
245     writeln!(f, "</footer>")?;
246 
247     writeln!(f, "</body>")?;
248     writeln!(f, "</html>")
249 }
250 
251 fn render_pic_page(pic: &GalleryEntry, full_build: bool) -> RenderResult {
252     let mut path = PathBuf::from(TARGET_PATH);
253     path.push(PAGES_PATH);
254     path.push(pic.file_name.clone() + ".html");
255 
256     // Only try to re-render HTML page in case the page is older than the
257     // image file
258     if !full_build {
259         if let (Ok(path_m), Some(pic_m)) = (fs::metadata(&path), &pic.metadata) {
260             if path_m.modified().unwrap() > pic_m.modified().unwrap() {
261                 return RenderResult::Skipped;
262             }
263         }
264     }
265 
266     let mut f = match File::create(&path) {
267         Ok(file) => file,
268         Err(err) => {
269             errorln!("Could not open file {path:?}: {err}");
270             return RenderResult::Failure;
271         }
272     };
273 
274     /// Does the deeds
275     fn write_file(f: &mut File, pic: &GalleryEntry) -> io::Result<()> {
276         writeln!(f, "<!DOCTYPE html>")?;
277         write_license(f)?;
278         writeln!(f, "<html lang=\"en\">")?;
279         writeln!(f, "<head>")?;
280         writeln!(
281             f,
282             "<title>{PAGE_TITLE} &dash; {name}</title>",
283             name = Escaped(&pic.file_name)
284         )?;
285         write_head(f)?;
286         writeln!(
287             f,
288             "<link rel=\"preload\" as=\"image\" href=\"{path}\">",
289             path = ThumbPath(pic),
290         )?;
291         writeln!(f, "</head>")?;
292 
293         writeln!(f, "<body>")?;
294         writeln!(f, "<main>")?;
295         writeln!(
296             f,
297             "<h1 class=\"picture-title\">{name}</h1>",
298             name = Escaped(&pic.file_name)
299         )?;
300 
301         if pic.caption.is_some() {
302             writeln!(f, "<figure>")?;
303         } else {
304             writeln!(f, "<figure aria-label=\"File {name}\">",
305                      name = Escaped(&pic.file_name))?;
306         }
307         writeln!(f, "<div id=\"picture\">")?;
308         writeln!(f, "<div>")?;
309 
310         writeln!(f, "<div class=\"picture-container\">")?;
311         writeln!(
312             f,
313             "<img alt=\"{alt}\" src=\"{path}\">",
314             alt = Escaped(&pic.alt),
315             path = ThumbPath(pic),
316         )?;
317         writeln!(f, "</div>")?;
318 
319         writeln!(f, "<nav id=\"picture-nav\">")?;
320         writeln!(f, "<ul>")?;
321         writeln!(
322             f,
323             "<li><a href=\"/{IMAGES_PATH}/{name}\">download</a></li>",
324             name = Escaped(&pic.file_name),
325         )?;
326         if let Some(src) = &pic.source {
327             writeln!(f, "<li><a href=\"{src}\">original source</a></li>")?;
328         }
329         writeln!(f, "</ul>")?;
330         writeln!(f, "</nav>")?;
331 
332         writeln!(f, "</div>")?;
333         writeln!(f, "</div>")?;
334         if let Some(caption) = &pic.caption {
335             writeln!(f, "<figcaption>")?;
336             writeln!(f, "{}", Escaped(caption))?;
337             writeln!(f, "</figcaption>")?;
338         }
339         writeln!(f, "</figure>")?;
340         writeln!(f, "</main>")?;
341 
342         writeln!(f, "<footer>")?;
343         write!(f, "original work by ")?;
344         if let Some(url) = &pic.author_url {
345             writeln!(f, "<a role=\"author\" href=\"{url}\">{author}</a>",
346                      author = Escaped(&pic.author))?;
347         } else {
348             writeln!(f, "{}", Escaped(&pic.author))?;
349         }
350         writeln!(f, "<br>")?;
351         match &pic.license {
352             LicenseType::Cc(license) => {
353                 writeln!(
354                     f,
355                     "licensed under <a role=\"license\" href=\"{url}\">{license}</a>",
356                     url = license.url()
357                 )?;
358             }
359             LicenseType::PublicDomain => writeln!(f, "this is public domain")?,
360             LicenseType::Proprietary => {
361                 writeln!(
362                     f,
363                     "this is distributed under a proprietary license"
364                 )?;
365             }
366         }
367         writeln!(f, "</footer>")?;
368 
369         writeln!(f, "</body>")?;
370         writeln!(f, "</html>")
371     }
372 
373     if let Err(err) = write_file(&mut f, pic) {
374         errorln!("Could not write to {path:?}: {err}");
375         RenderResult::Failure
376     } else {
377         RenderResult::Success
378     }
379 }
380 
381 /// Prints the common head elements to a given file
382 fn write_head(f: &mut File) -> io::Result<()> {
383     writeln!(
384         f,
385         "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
386     )?;
387     writeln!(f, "<meta name=\"author\" content=\"{AUTHOR}\">")?;
388     writeln!(f, "<meta name=\"copyright\" content=\"{LICENSE}\">")?;
389     writeln!(
390         f,
391         "<meta content=\"text/html; charset=utf-8\" http-equiv=\"content-type\">"
392     )?;
393     writeln!(f,
394              "<link rel=\"icon\" type=\"image/svg+xml\" sizes=\"16x16 24x24 32x32 48x48 64x64 128x128 256x256 512x512\" href=\"/{FAVICON_PATH}\">")?;
395     writeln!(f, "<link rel=\"stylesheet\" href=\"/{STYLES_PATH}\">")?;
396     writeln!(f, "<link rel=\"preload\" as=\"font\" href=\"/{FONTS_PATH}/alfa-slab.woff2\">")
397 }
398 
399 /// Prints a HTML comment with GPL licensing info
400 fn write_license(f: &mut File) -> io::Result<()> {
401     writeln!(
402         f,
403         "<!-- This program is free software: you can redistribute it and/or modify"
404     )?;
405     writeln!(
406         f,
407         "     it under the terms of the GNU General Public License as published by"
408     )?;
409     writeln!(
410         f,
411         "     the Free Software Foundation, either version 3 of the License, or"
412     )?;
413     writeln!(f, "     (at your option) any later version.\n")?;
414     writeln!(
415         f,
416         "     This program is distributed in the hope that it will be useful,"
417     )?;
418     writeln!(
419         f,
420         "     but WITHOUT ANY WARRANTY; without even the implied warranty of"
421     )?;
422     writeln!(
423         f,
424         "     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the"
425     )?;
426     writeln!(f, "     GNU General Public License for more details.\n")?;
427     writeln!(
428         f,
429         "     You should have received a copy of the GNU General Public License"
430     )?;
431     writeln!(
432         f,
433         "     along with this program. If not, see <https://www.gnu.org/licenses/>. -->"
434     )
435 }
436 
437 fn render_thumbnail(pic: GalleryEntry, full_build: bool) -> RenderResult {
438     let thumb_path = thumb_path(&pic);
439 
440     // Here we do not want to call fs::symlink_metada: we want to know when was
441     // the symlink last updated
442     let thumb_meta = fs::metadata(&thumb_path);
443 
444     if !full_build {
445         if let (Ok(thumb_m), Some(pic_m)) = (&thumb_meta, &pic.metadata) {
446             if thumb_m.modified().unwrap() > pic_m.modified().unwrap() {
447                 warnln!(
448                     "Skipped rendering the thumbnail for {name:?} (use {FULL_BUILD_OPT} to overwrite)",
449                     name = pic.file_name
450                 );
451                 return RenderResult::Skipped;
452             }
453         }
454     }
455 
456     match pic.file_format {
457         FileFormat::TeX => {
458             // tikztosvg -o thumb_path
459             //           -p relsize
460             //           -p xfrac
461             //           -l matrix
462             //           -l patterns
463             //           -l shapes.geometric
464             //           -l arrows
465             //           -q
466             //           pic.path
467             let mut tikztosvg_cmd = Command::new("tikztosvg");
468             tikztosvg_cmd.arg("-o")
469                 .arg(thumb_path.clone())
470                 .args([
471                     "-p", "relsize",
472                     "-p", "xfrac",
473                     "-l", "matrix",
474                     "-l", "patterns",
475                     "-l", "shapes.geometric",
476                     "-l", "arrows",
477                     "-q",
478                 ])
479                 .arg(pic.path);
480 
481             match tikztosvg_cmd.status() {
482                 Ok(c) if !c.success() => {
483                     errorln!(
484                         "Failed to run tikztosvg: {command:?} returned exit code {code}",
485                         command = tikztosvg_cmd,
486                         code = c
487                     );
488                     return RenderResult::Failure;
489                 }
490                 Err(err) => {
491                     errorln!("Failed to run tikztosvg: {err}");
492                     return RenderResult::Failure;
493                 }
494                 _ => {}
495             }
496         },
497         FileFormat::Svg => {
498             let mut src_path = PathBuf::from(TARGET_PATH);
499             src_path.push(IMAGES_PATH);
500             src_path.push(&pic.file_name);
501 
502             // Here we need the absolute path of the image to prevent issues
503             // with symlinks
504             let src_path = match fs::canonicalize(&src_path) {
505                 Ok(path) => path,
506                 Err(err) => {
507                     errorln!(
508                         "Failed to create symlink for {thumb:?}: Could not get absolute path of {src:?}: {err}",
509                         thumb = thumb_path,
510                         src = src_path,
511                         err = err,
512                     );
513                     return RenderResult::Failure;
514                 }
515             };
516 
517             // Delete the thumbnail file if it exists already: fs::symlink does
518             // not override files
519             if let Ok(true) = thumb_meta.map(|m| m.is_file() || m.is_symlink()) {
520                 let _ = fs::remove_file(&thumb_path);
521             }
522 
523             if let Err(err) = unix::fs::symlink(&src_path, &thumb_path) {
524                 errorln!(
525                     "Failed to create symlink {thumb:?} -> {src:?}: {err}",
526                     thumb = thumb_path,
527                     src = src_path,
528                 );
529                 return RenderResult::Failure;
530             }
531         },
532         FileFormat::Jpeg | FileFormat::Png => {
533             let mut thumb_file = match File::create(&thumb_path) {
534                 Ok(f)    => f,
535                 Err(err) => {
536                     errorln!(
537                         "Couldn't open thumbnail file {thumb_path:?}: {err}"
538                     );
539                     return RenderResult::Failure;
540                 }
541             };
542 
543             let img_reader = match ImageReader::open(&pic.path) {
544                 Ok(r)    => r,
545                 Err(err) => {
546                     errorln!(
547                         "Couldn't open file {path:?} to render thumbnail: {err}",
548                         path = pic.file_name,
549                     );
550                     return RenderResult::Failure;
551                 }
552             };
553 
554             let img = match img_reader.decode() {
555                 Ok(img)  => img,
556                 Err(err) => {
557                     errorln!(
558                         "Faileded to decode image file {name:?}: {err}",
559                         name = pic.file_name,
560                     );
561                     return RenderResult::Failure;
562                 }
563             };
564 
565             let h = THUMB_HEIGHT;
566             let w = (h * img.width()) / img.height();
567 
568             // We should make sure that the image is in the RGBA8 format so that
569             // the webp crate can encode it
570             let img = DynamicImage::from(img.thumbnail(w, h).into_rgba8());
571             let mem = webp::Encoder::from_image(&img)
572                 .expect("image should be in the RGBA8 format")
573                 .encode(WEBP_IMAGE_QUALITY);
574 
575             if let Err(err) = thumb_file.write_all(&mem) {
576                 errorln!(
577                     "Couldn't write thumnail to file {path:?}: {err}",
578                     path = thumb_path
579                 );
580                 return RenderResult::Failure;
581             }
582         }
583     }
584 
585     infoln!("Rendered thumbnail for {name:?}", name = pic.file_name);
586     RenderResult::Success
587 }
588 
589 /// Helper to get the correct thumbnail path for a given entry
590 fn thumb_path(pic: &GalleryEntry) -> PathBuf {
591     let mut result = PathBuf::from(TARGET_PATH);
592     result.push(THUMBS_PATH);
593 
594     match pic.file_format {
595         FileFormat::TeX => {
596             result.push(pic.file_name.clone() + ".svg");
597         }
598         FileFormat::Svg => {
599             result.push(pic.file_name.clone());
600         }
601         FileFormat::Jpeg | FileFormat::Png => {
602             result.push(pic.file_name.clone() + ".webp");
603         }
604     }
605 
606     result
607 }
608 
609 impl<'a> Display for ThumbPath<'a> {
610     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
611         write!(f, "/{THUMBS_PATH}/{name}", name = Escaped(&self.0.file_name))?;
612 
613         match self.0.file_format {
614             FileFormat::TeX => write!(f, ".svg")?,
615             FileFormat::Svg => {}
616             FileFormat::Jpeg | FileFormat::Png => write!(f, ".webp")?,
617         }
618 
619         Ok(())
620     }
621 }
622 
623 impl<'a> Display for Escaped<'a> {
624     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
625         for c in self.0.chars() {
626             match c {
627                 '<'  => write!(f, "&lt;")?,
628                 '>'  => write!(f, "&gt;")?,
629                 '&'  => write!(f, "&amp;")?,
630                 '"'  => write!(f, "&quot;")?,
631                 '\'' => write!(f, "&apos;")?,
632                 c    => c.fmt(f)?,
633             }
634         }
635 
636         Ok(())
637     }
638 }
639 
640 impl<'a> Display for ArgList<'a> {
641     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
642         let mut first = true;
643 
644         for arg in self.0 {
645             if first {
646                 first = false;
647                 write!(f, "{:?}", arg)?;
648             } else {
649                 write!(f, " {:?}", arg)?;
650             }
651         }
652 
653         Ok(())
654     }
655 }