stapix
Yet another static page generator for photo galleries
main.rs (15964B)
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, Path}, 10 process::ExitCode, 11 sync::mpsc, 12 }; 13 use gallery_entry::{GalleryEntry, LicenseType}; 14 use threadpool::ThreadPool; 15 16 #[macro_use] 17 mod log; 18 mod gallery_entry; 19 20 /// A wrapper for HTML-escaped strings 21 struct Escaped<'a>(pub &'a str); 22 23 /// A wrapper to display lists of command line arguments 24 struct ArgList<'a>(pub &'a [String]); 25 26 #[derive(Clone, Copy, PartialEq, Eq)] 27 enum RenderResult { 28 Skipped, 29 Success, 30 Failure, 31 } 32 33 const FULL_BUILD_OPT: &str = "--full-build"; 34 35 const TARGET_PATH: &str = "./site"; 36 const PAGES_PATH: &str = "pix"; 37 const PHOTOS_PATH: &str = "assets/photos"; 38 const THUMBS_PATH: &str = "assets/thumbs"; 39 const FAVICON_PATH: &str = "assets/favicon.ico"; 40 const ICON_PATH: &str = "assets/icon.svg"; 41 const STYLES_PATH: &str = "styles.css"; 42 43 const PAGE_TITLE: &str = "Pablo's Photo Gallery"; 44 const AUTHOR: &str = "Pablo"; 45 const LICENSE: &str = "GPLv3"; 46 47 /// WebP image quality 48 const IMAGE_QUALITY: f32 = 50.0; 49 50 /// Target height of the thumbnails, depending on wether the image is vertical 51 /// or horizontal 52 const HORIZONTAL_THUMB_HEIGHT: u32 = 300; 53 const VERTICAL_THUMB_HEIGHT: u32 = 800; 54 55 fn main() -> ExitCode { 56 infoln!("Running {package} version {version}", 57 package = env!("CARGO_PKG_NAME"), 58 version = env!("CARGO_PKG_VERSION")); 59 60 let args: Vec<String> = env::args().collect(); 61 62 let (program, config, full_build) = match &args[..] { 63 [program, config] => (program, config, false), 64 [program, config, opt] if opt == FULL_BUILD_OPT => { 65 (program, config, true) 66 } 67 [program, _config, ..] => { 68 errorln!("Unknown arguments: {}", ArgList(&args[2..])); 69 usage!(program); 70 return ExitCode::FAILURE; 71 } 72 [program] => { 73 errorln!("Expected 1 command line argument, found none"); 74 usage!(program); 75 return ExitCode::FAILURE; 76 } 77 [] => unreachable!("args always contains at least the input program"), 78 }; 79 80 let f = File::open(config); 81 match f.map(serde_yaml::from_reader::<_, Vec<GalleryEntry>>) { 82 // Error opening the config file 83 Err(err) => { 84 errorln!("Couldn't open {config:?}: {err}"); 85 usage!(program); 86 ExitCode::FAILURE 87 } 88 // Error parsing the config file 89 Ok(Err(err)) => { 90 errorln!("Couldn't parse {config:?}: {err}"); 91 usage_config!(); 92 ExitCode::FAILURE 93 } 94 Ok(Ok(pics)) => render_gallery(pics, full_build), 95 } 96 } 97 98 /// Coordinates the rendering of all the pages and file conversions 99 fn render_gallery(pics: Vec<GalleryEntry>, full_build: bool) -> ExitCode { 100 info!("Copying image files to the target directory..."); 101 102 for pic in &pics { 103 let mut target_path = PathBuf::from(TARGET_PATH); 104 target_path.push(PHOTOS_PATH); 105 target_path.push(&pic.file_name); 106 107 if let Err(err) = fs::copy(&pic.path, &target_path) { 108 errorln!( 109 "Couldn't copy file {src:?} to {target:?}: {err}", 110 src = pic.path, 111 target = target_path, 112 ); 113 return ExitCode::FAILURE; 114 } 115 } 116 117 info_done!(); 118 119 // ======================================================================== 120 for pic in &pics { 121 if pic.alt.is_empty() { 122 warnln!( 123 "Empty text alternative was specified for the file {name:?}", 124 name = pic.file_name 125 ); 126 } 127 } 128 129 // ======================================================================== 130 let num_threads = min(num_cpus::get() + 1, pics.len()); 131 let rendering_pool = ThreadPool::with_name( 132 String::from("thumbnails renderer"), 133 num_threads 134 ); 135 let (sender, reciever) = mpsc::channel(); 136 137 infoln!("Started rendering WebP thumbnails (using {n} threads)", 138 n = num_threads); 139 140 for pic in &pics { 141 let sender = sender.clone(); 142 let pic = pic.clone(); 143 rendering_pool.execute(move || { 144 sender.send(render_thumbnail(pic, full_build)) 145 .expect("channel should still be alive awaiting for the completion of this task"); 146 }); 147 } 148 149 for _ in 0..pics.len() { 150 match reciever.recv() { 151 Ok(RenderResult::Failure) => return ExitCode::FAILURE, 152 Ok(RenderResult::Success | RenderResult::Skipped) => {} 153 Err(_) => { 154 // Propagate the panic to the main thread: reciever.recv should 155 // only fail if some of the rendering threads panicked 156 panic!("rendering thread panicked!"); 157 } 158 } 159 } 160 161 infoln!("Done rendering WebP thumbnails!"); 162 163 // ======================================================================== 164 info!("Rendering index.html..."); 165 if render_index(&pics).is_err() { 166 return ExitCode::FAILURE; 167 } 168 info_done!(); 169 170 for pic in pics { 171 info!("Rendering HTML page for {name:?}...", name = pic.file_name); 172 match render_pic_page(&pic, full_build) { 173 RenderResult::Success => info_done!(), 174 RenderResult::Skipped => { 175 info_done!("Skipped! (use {FULL_BUILD_OPT} to overwrite)"); 176 } 177 RenderResult::Failure => return ExitCode::FAILURE, 178 } 179 } 180 181 ExitCode::SUCCESS 182 } 183 184 fn render_index(pics: &Vec<GalleryEntry>) -> io::Result<()> { 185 let mut path = PathBuf::from(TARGET_PATH); 186 path.push("index.html"); 187 188 let mut f = File::create(path)?; 189 190 writeln!(f, "<!DOCTYPE html>")?; 191 write_license(&mut f)?; 192 writeln!(f, "<html lang=\"en\">")?; 193 writeln!(f, "<head>")?; 194 writeln!(f, "<title>{PAGE_TITLE}</title>")?; 195 write_head(&mut f)?; 196 197 for pic in pics.iter().take(10) { 198 // TODO: Preload mp4 thumbnails for GIF files 199 writeln!( 200 f, 201 "<link rel=\"preload\" as=\"image\" href=\"/{THUMBS_PATH}/{name}.webp\">", 202 name = Escaped(&pic.file_name) 203 )?; 204 } 205 206 writeln!(f, "</head>")?; 207 208 writeln!(f, "<body>")?; 209 write_nav(&mut f)?; 210 writeln!(f, "<main>")?; 211 writeln!(f, "<ul id=\"gallery\">")?; 212 213 for pic in pics { 214 writeln!(f, "<li>")?; 215 writeln!( 216 f, 217 "<a aria-label=\"{name}\" href=\"/{PAGES_PATH}/{name}.html\">", 218 name = Escaped(&pic.file_name) 219 )?; 220 // TODO: Link to mp4 thumbnails for GIF files 221 writeln!( 222 f, 223 "<img alt=\"{alt}\" src=\"/{THUMBS_PATH}/{name}.webp\">", 224 alt = Escaped(&pic.alt), 225 name = Escaped(&pic.file_name) 226 )?; 227 writeln!(f, "</a>\n</li>")?; 228 } 229 230 writeln!(f, "</ul>")?; 231 writeln!(f, "</main>")?; 232 233 writeln!(f, "<footer>")?; 234 writeln!( 235 f, 236 "made with 💛 by <a role=\"author\" href=\"https://pablopie.xyz\">@pablo</a>" 237 )?; 238 writeln!(f, "</footer>")?; 239 240 writeln!(f, "</body>")?; 241 writeln!(f, "</html>") 242 } 243 244 fn render_pic_page(pic: &GalleryEntry, full_build: bool) -> RenderResult { 245 let mut path = PathBuf::from(TARGET_PATH); 246 path.push(PAGES_PATH); 247 path.push(pic.file_name.clone() + ".html"); 248 249 // Only try to re-render HTML page in case the page is older than the 250 // image file 251 if !full_build && !needs_update(&path, &pic.path) { 252 return RenderResult::Skipped; 253 } 254 255 let mut f = match File::create(&path) { 256 Ok(file) => file, 257 Err(err) => { 258 errorln!("Could not open file {path:?}: {err}"); 259 return RenderResult::Failure; 260 } 261 }; 262 263 /// Does the deeds 264 fn write_file(f: &mut File, pic: &GalleryEntry) -> io::Result<()> { 265 writeln!(f, "<!DOCTYPE html>")?; 266 write_license(f)?; 267 writeln!(f, "<html lang=\"en\">")?; 268 writeln!(f, "<head>")?; 269 writeln!( 270 f, 271 "<title>{PAGE_TITLE} ‐ {name}</title>", 272 name = Escaped(&pic.file_name) 273 )?; 274 write_head(f)?; 275 writeln!( 276 f, 277 "<link rel=\"preload\" as=\"image\" href=\"/{PHOTOS_PATH}/{n}\">", 278 n = Escaped(&pic.file_name) 279 )?; 280 writeln!(f, "</head>")?; 281 282 writeln!(f, "<body>")?; 283 write_nav(f)?; 284 285 writeln!(f, "<main>")?; 286 if pic.caption.is_some() { 287 writeln!(f, "<figure>")?; 288 } else { 289 writeln!(f, "<figure aria-label=\"File {name}\">", 290 name = Escaped(&pic.file_name))?; 291 } 292 writeln!(f, "<div id=\"picture-container\">")?; 293 writeln!( 294 f, 295 "<img alt=\"{alt}\" src=\"/{PHOTOS_PATH}/{file_name}\">", 296 alt = Escaped(&pic.alt), 297 file_name = Escaped(&pic.file_name) 298 )?; 299 writeln!(f, "</div>")?; 300 if let Some(caption) = &pic.caption { 301 writeln!(f, "<figcaption>")?; 302 writeln!(f, "{}", Escaped(caption))?; 303 writeln!(f, "</figcaption>")?; 304 } 305 writeln!(f, "</figure>")?; 306 writeln!(f, "</main>")?; 307 308 writeln!(f, "<footer>")?; 309 write!(f, "original work by ")?; 310 if let Some(url) = &pic.author_url { 311 writeln!(f, "<a role=\"author\" href=\"{url}\">{author}</a>", 312 author = Escaped(&pic.author))?; 313 } else { 314 writeln!(f, "{}", Escaped(&pic.author))?; 315 } 316 writeln!(f, "<br>")?; 317 if let LicenseType::Cc(license) = &pic.license { 318 writeln!(f, "licensed under <a role=\"license\" href=\"{url}\">{license}</a>", 319 url = license.url())?; 320 } else { 321 writeln!(f, "this is public domain")?; 322 } 323 writeln!(f, "</footer>")?; 324 325 writeln!(f, "</body>")?; 326 writeln!(f, "</html>") 327 } 328 329 if let Err(err) = write_file(&mut f, pic) { 330 errorln!("Could not write to {path:?}: {err}"); 331 RenderResult::Failure 332 } else { 333 RenderResult::Success 334 } 335 } 336 337 fn write_nav(f: &mut File) -> io::Result<()> { 338 writeln!(f, "<header>")?; 339 writeln!(f, "<nav>")?; 340 writeln!(f, "<img aria-hidden=\"true\" alt=\"Website icon\" width=\"24\" height=\"24\" src=\"/{ICON_PATH}\">")?; 341 writeln!(f, "<a href=\"/index.html\">photos.pablopie.xyz</a>")?; 342 writeln!(f, "</nav>")?; 343 writeln!(f, "</header>") 344 } 345 346 /// Prints the common head elements to a given file 347 fn write_head(f: &mut File) -> io::Result<()> { 348 writeln!( 349 f, 350 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">" 351 )?; 352 writeln!(f, "<meta name=\"author\" content=\"{AUTHOR}\">")?; 353 writeln!(f, "<meta name=\"copyright\" content=\"{LICENSE}\">")?; 354 writeln!( 355 f, 356 "<meta content=\"text/html; charset=utf-8\" http-equiv=\"content-type\">" 357 )?; 358 writeln!(f, "<link rel=\"icon\" href=\"/{FAVICON_PATH}\" type=\"image/x-icon\" sizes=\"16x16 24x24 32x32\">")?; 359 writeln!(f, "<link rel=\"stylesheet\" href=\"/{STYLES_PATH}\">") 360 } 361 362 /// Prints a HTML comment with GPL licensing info 363 fn write_license(f: &mut File) -> io::Result<()> { 364 writeln!( 365 f, 366 "<!-- This program is free software: you can redistribute it and/or modify" 367 )?; 368 writeln!( 369 f, 370 " it under the terms of the GNU General Public License as published by" 371 )?; 372 writeln!( 373 f, 374 " the Free Software Foundation, either version 3 of the License, or" 375 )?; 376 writeln!(f, " (at your option) any later version.\n")?; 377 writeln!( 378 f, 379 " This program is distributed in the hope that it will be useful," 380 )?; 381 writeln!( 382 f, 383 " but WITHOUT ANY WARRANTY; without even the implied warranty of" 384 )?; 385 writeln!( 386 f, 387 " MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the" 388 )?; 389 writeln!(f, " GNU General Public License for more details.\n")?; 390 writeln!( 391 f, 392 " You should have received a copy of the GNU General Public License" 393 )?; 394 writeln!( 395 f, 396 " along with this program. If not, see <https://www.gnu.org/licenses/>. -->" 397 ) 398 } 399 400 // TODO: Render GIF files as mp4 instead 401 fn render_thumbnail(pic: GalleryEntry, full_build: bool) -> RenderResult { 402 let mut thumb_path = PathBuf::from(TARGET_PATH); 403 thumb_path.push(THUMBS_PATH); 404 thumb_path.push(pic.file_name.clone() + ".webp"); 405 406 // Only try to render thumbnail in case the thumbnail file in the machine 407 // is older than the source file 408 if !full_build && !needs_update(&thumb_path, &pic.path) { 409 warnln!( 410 "Skipped rendering the thumbnail for {name:?} (use {FULL_BUILD_OPT} to overwrite)", 411 name = pic.file_name 412 ); 413 return RenderResult::Skipped; 414 } 415 416 let mut thumb_file = match File::create(&thumb_path) { 417 Ok(f) => f, 418 Err(err) => { 419 errorln!( 420 "Couldn't open WebP thumbnail file {thumb_path:?}: {err}" 421 ); 422 return RenderResult::Failure; 423 } 424 }; 425 426 let img_reader = match ImageReader::open(&pic.path) { 427 Ok(r) => r, 428 Err(err) => { 429 errorln!( 430 "Couldn't open file {path:?} to render WebP thumbnail: {err}", 431 path = pic.file_name, 432 err = err 433 ); 434 return RenderResult::Failure; 435 } 436 }; 437 438 let img = match img_reader.decode() { 439 Ok(img) => img, 440 Err(err) => { 441 errorln!( 442 "Faileded to decode image file {name:?}: {err}", 443 name = pic.file_name, 444 ); 445 return RenderResult::Failure; 446 } 447 }; 448 449 let h = if img.width() > img.height() { 450 HORIZONTAL_THUMB_HEIGHT 451 } else { 452 VERTICAL_THUMB_HEIGHT 453 }; 454 let w = (h * img.width()) / img.height(); 455 456 // We should make sure that the image is in the RGBA8 format so that 457 // the webp crate can encode it 458 let img = DynamicImage::from(img.thumbnail(w, h).into_rgba8()); 459 let mem = webp::Encoder::from_image(&img) 460 .expect("image should be in the RGBA8 format") 461 .encode(IMAGE_QUALITY); 462 463 if let Err(err) = thumb_file.write_all(&mem) { 464 errorln!( 465 "Couldn't write WebP thumnail to file {path:?}: {err}", 466 path = thumb_path 467 ); 468 return RenderResult::Failure; 469 } 470 471 infoln!("Rendered WebP thumbnail for {name:?}", name = pic.file_name); 472 RenderResult::Success 473 } 474 475 /// Returns `false` if both `p1` and `p2` exist and and `p1` is newer than 476 /// `f2`. Returns `true` otherwise 477 fn needs_update<P1: AsRef<Path>, P2: AsRef<Path>>(p1: P1, p2: P2) -> bool { 478 if let (Ok(m1), Ok(m2)) = (fs::metadata(&p1), fs::metadata(&p2)) { 479 if m1.modified().unwrap() > m2.modified().unwrap() { 480 return false; 481 } 482 } 483 484 true 485 } 486 487 impl<'a> Display for Escaped<'a> { 488 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 489 for c in self.0.chars() { 490 match c { 491 '<' => write!(f, "<")?, 492 '>' => write!(f, ">")?, 493 '&' => write!(f, "&")?, 494 '"' => write!(f, """)?, 495 '\'' => write!(f, "'")?, 496 c => c.fmt(f)?, 497 } 498 } 499 500 Ok(()) 501 } 502 } 503 504 impl<'a> Display for ArgList<'a> { 505 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 506 let mut first = true; 507 508 for arg in self.0 { 509 if first { 510 first = false; 511 write!(f, "{:?}", arg)?; 512 } else { 513 write!(f, " {:?}", arg)?; 514 } 515 } 516 517 Ok(()) 518 } 519 }