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&apos;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} &dash; {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, "&lt;")?,
492                 '>'  => write!(f, "&gt;")?,
493                 '&'  => write!(f, "&amp;")?,
494                 '"'  => write!(f, "&quot;")?,
495                 '\'' => write!(f, "&apos;")?,
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 }