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} ‐ {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, "<")?, 628 '>' => write!(f, ">")?, 629 '&' => write!(f, "&")?, 630 '"' => write!(f, """)?, 631 '\'' => write!(f, "'")?, 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 }