From 9434eb835d07846e45bf838881296b08d554464b Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Tue, 6 May 2025 18:09:31 -0700 Subject: [PATCH] basic image HTML rendering. Current tests pass! --- Cargo.lock | 7 ++ Cargo.toml | 1 + resources/skel/_templates/photo.html | 8 +- src/generate.rs | 57 ++++++++++- src/generate/album_dir.rs | 51 ++++------ tests/test_generate.rs | 145 +++++++++++++-------------- 6 files changed, 156 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2a6d5a..79f48aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,6 +415,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "generic-array" version = "0.14.7" @@ -871,6 +877,7 @@ dependencies = [ "anyhow", "clap", "env_logger", + "fs_extra", "image", "log", "mktemp", diff --git a/Cargo.toml b/Cargo.toml index 7a82700..f7a5edf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ edition = "2024" anyhow = "^1.0" clap = { version = "^4.5", features = ["derive"] } env_logger = "^0.11.8" +fs_extra = "^1.3.0" image = "^0.25.6" log = "^0.4.27" serde = { version = "^1.0", features = ["derive"] } diff --git a/resources/skel/_templates/photo.html b/resources/skel/_templates/photo.html index bb7e4af..3ede869 100644 --- a/resources/skel/_templates/photo.html +++ b/resources/skel/_templates/photo.html @@ -17,7 +17,7 @@ {% block content %}
- +
- {% if image_path.description %} - {{ image_path.description | safe }} + {% if image.description %} + {{ image.description | safe }} {% endif %}
- view full size + view full size
{% endblock %} diff --git a/src/generate.rs b/src/generate.rs index 5cc8e82..33e810d 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -102,13 +102,14 @@ impl TryFrom<&AlbumDir> for AlbumContext { } /// A Tera context for slide (individual image) pages -#[derive(Serialize)] +#[derive(Serialize, Debug)] struct SlideContext { // TODO: Path or String? + // Path required to get back to the root album root_path: PathBuf, image: Image, - prev_image: Image, - next_image: Image, + prev_image: Option, + next_image: Option, } pub fn generate(root_path: &PathBuf) -> anyhow::Result { @@ -120,6 +121,7 @@ pub fn generate(root_path: &PathBuf) -> anyhow::Result { env::set_current_dir(root_path)?; let album = AlbumDir::try_from(root_path)?; + fs::create_dir(&config.output_dir)?; copy_static(&config)?; generate_images(&config, &album)?; generate_html(&config, &album)?; @@ -129,6 +131,13 @@ pub fn generate(root_path: &PathBuf) -> anyhow::Result { } fn copy_static(config: &Config) -> anyhow::Result<()> { + let dst = &config.output_dir.join("static"); + log::info!("Copying static files from _static to {}", dst.display()); + fs_extra::dir::copy( + "_static", + dst, + &fs_extra::dir::CopyOptions::new().content_only(true), + )?; Ok(()) } @@ -139,6 +148,15 @@ fn generate_images(config: &Config, album: &AlbumDir) -> anyhow::Result<()> { for img in album.iter() { let orig_image = image::open(&img.path)?; + let orig_path = output_path.join(&img.path); + log::info!( + "Copying original {} -> {}", + img.path.display(), + orig_path.display() + ); + fs::create_dir_all(orig_path.parent().unwrap_or(Path::new("")))?; + orig_image.save(&orig_path)?; + let thumb_path = output_path.join(&img.thumb_path); log::info!( "Resizing {} -> {}", @@ -180,7 +198,6 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> { .ok_or(anyhow!("Missing _templates dir in album dir"))?, )?; - // Queue of album dir and depth (distance from root AlbumDir) let mut dir_queue: VecDeque<&AlbumDir> = VecDeque::from([album]); while let Some(album) = dir_queue.pop_front() { let html_path = output_path.join(&album.path).join("index.html"); @@ -197,5 +214,37 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> { } } + let all_images: Vec<&Image> = album.iter().collect(); + for (pos, img) in all_images.iter().enumerate() { + let img: &Image = *img; + let prev_image: Option<&Image> = match pos { + 0 => None, + n => Some(&all_images[n - 1]), + }; + let next_image: Option<&Image> = all_images.get(pos + 1).map(|i| *i); + + // Find the path to the root by counting the parts of the path + let mut path_to_root = PathBuf::new(); + if let Some(parent) = img.path.parent() { + let mut parent = parent.to_path_buf(); + while parent.pop() { + path_to_root = path_to_root.join(".."); + } + } + + log::info!("Rendering image {}", img.html_path.display()); + let ctx = SlideContext { + root_path: path_to_root, + image: img.clone(), + prev_image: prev_image.cloned(), + next_image: next_image.cloned(), + }; + log::debug!("Image context: {ctx:?}"); + fs::write( + output_path.join(&img.html_path), + tera.render("photo.html", &tera::Context::from_serialize(&ctx)?)?, + )?; + } + Ok(()) } diff --git a/src/generate/album_dir.rs b/src/generate/album_dir.rs index 00b6d24..40d7963 100644 --- a/src/generate/album_dir.rs +++ b/src/generate/album_dir.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; use image::ImageReader; use serde::Serialize; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; use std::slice::Iter; @@ -21,6 +21,7 @@ pub struct AlbumDir { impl AlbumDir { /// Returns an iterator over all images in the album and subalbums + // TODO: Rename to iter_images() and make separate one for dirs? pub fn iter(&self) -> AlbumIter { AlbumIter::new(self) } @@ -145,10 +146,11 @@ impl<'a> Iterator for AlbumIter<'a> { } } -#[derive(Clone, Hash, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)] pub struct Image { /// Path to the image, relative to the root album pub path: PathBuf, + pub filename: OsString, /// Text description of the image which is displayed below it on the HTML page pub description: String, @@ -163,17 +165,25 @@ pub struct Image { impl Image { pub fn new(path: PathBuf, description: String) -> anyhow::Result { + let filename = path + .file_name() + .ok_or(anyhow!( + "Image path {} is missing a filename", + path.display() + ))? + .into(); let thumb_filename = Self::slide_filename(&path, "thumb", true)?; - let thumb_path = Self::slide_path(&path, "thumb")?; + let thumb_path = Self::slide_path(&path, &thumb_filename); let screen_filename = Self::slide_filename(&path, "screen", true)?; - let screen_path = Self::slide_path(&path, "screen")?; + let screen_path = Self::slide_path(&path, &screen_filename); // TODO: add "slides" in html path? let html_filename = Self::slide_filename(&path, "html", false)?; - let html_path = path.with_extension("html"); + let html_path = Self::slide_path(&path, &html_filename); Ok(Image { path, description, + filename, thumb_filename, thumb_path, screen_filename, @@ -209,30 +219,13 @@ impl Image { Ok(new_name.into()) } - /// Returns the path to the file in the slides dir with the given extention insert, e.g. - /// "thumb" or "display" - fn slide_path(path: &PathBuf, ext: &str) -> anyhow::Result { - let new_ext = match path.extension() { - Some(e) => { - ext.to_string() - + "." - + e.to_str().ok_or(anyhow!( - "Image {} extension is not valid UTF-8", - path.display() - ))? - } - None => ext.to_string(), - }; - - let new_path = path.with_extension(new_ext); - let new_name = new_path - .file_name() - .ok_or(anyhow!("Image {} missing a file name", path.display()))?; - let parent = path - .parent() - .ok_or(anyhow!("Image {} has no parent dir", path.display()))?; - - Ok(parent.join("slides").join(new_name)) + /// Returns the path to the file in the slides dir given the path to the original image + fn slide_path(path: &PathBuf, file_name: &OsStr) -> PathBuf { + let mut new_path = path.clone(); + new_path.pop(); + new_path.push("slides"); + new_path.push(&file_name); + new_path } } diff --git a/tests/test_generate.rs b/tests/test_generate.rs index 03ebdf0..622a139 100644 --- a/tests/test_generate.rs +++ b/tests/test_generate.rs @@ -4,7 +4,7 @@ use mktemp::Temp; use photojawn::generate::generate; use photojawn::skel::make_skeleton; use std::collections::{HashSet, VecDeque}; -use std::fs; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; #[test] @@ -26,102 +26,95 @@ fn make_test_album() -> Temp { let tmpdir = Temp::new_dir().unwrap(); let source_path = Path::new("resources/test_album"); + log::info!("Creating test album in {}", tmpdir.display()); make_skeleton(&tmpdir.to_path_buf()).unwrap(); - - let mut dirs: VecDeque = VecDeque::from([source_path.to_path_buf()]); - while let Some(dir) = dirs.pop_front() { - for entry in dir.read_dir().unwrap() { - let entry_path = entry.unwrap().path(); - let path_in_album = entry_path.strip_prefix(&source_path).unwrap(); - if entry_path.is_dir() { - dirs.push_back(entry_path); - } else { - let dest_path = tmpdir.join(&path_in_album); - fs::create_dir_all(dest_path.parent().unwrap()).unwrap(); - fs::copy(&entry_path, &dest_path).unwrap(); - log::debug!("Copied {} -> {}", entry_path.display(), dest_path.display()); - } - } - } + fs_extra::dir::copy( + &source_path, + &tmpdir, + &fs_extra::dir::CopyOptions::new().content_only(true), + ) + .unwrap(); tmpdir } /// Does basic sanity checks on an output album -fn check_album(album_dir: PathBuf) -> anyhow::Result<()> { - log::debug!("Checking album dir {}", album_dir.display()); +fn check_album(root_path: PathBuf) -> anyhow::Result<()> { + log::debug!("Checking album dir {}", root_path.display()); // The _static dir should have gotten copied into /static - assert!(album_dir.join("static/index.css").exists()); + assert!(root_path.join("static/index.css").exists()); - let mut dirs: VecDeque = VecDeque::from([album_dir]); + let mut dirs: VecDeque = VecDeque::from([root_path.clone()]); while let Some(dir) = dirs.pop_front() { + let mut files: Vec = Vec::new(); for entry in dir.read_dir().unwrap() { let path = entry.unwrap().path(); - if path.is_dir() && !path.ends_with(Path::new("slides")) { - check_album(path.clone())?; + if path.is_dir() + && !path.ends_with(Path::new("slides")) + && path.file_name() != Some(OsStr::new("static")) + { + dirs.push_back(path.clone()); + } else if path.is_file() { + files.push(path); } - let files: Vec = path - .read_dir() - .unwrap() - .into_iter() - .map(|e| e.unwrap().path()) - .filter(|e| e.is_file()) - .collect(); + } - // There should be an index.html - assert!(path.join("index.html").exists()); + // There should be an index.html + let index_path = dir.join("index.html"); + assert!( + index_path.exists(), + "Expected {} to exist", + index_path.display() + ); - // There should be a cover image - let cover_path = path.join("cover.jpg"); - assert!(&cover_path.exists()); + // There should be a slides dir + let slides_path = dir.join("slides"); + assert!( + slides_path.is_dir(), + "Expected {} to be a dir", + slides_path.display() + ); - // The cover should be equal contents to some other image - let cover_contents = fs::read(&cover_path).unwrap(); - let mut found = false; - for file in &files { - if file != &cover_path { - let file_contents = fs::read(file).unwrap(); - if file_contents == cover_contents { - found = true; - } + // No two images should have the same path + let image_set: HashSet<&PathBuf> = files.iter().collect(); + assert_eq!(image_set.len(), files.len()); + + // For each image in the album (including the cover), in slides there should be a: + // - .html + // - .screen. + // - .thumb. + for file in &files { + if let Some(ext) = file.extension() { + if ext != "jpg" { + continue; } } - if !found { - panic!( - "cover.jpg in {} does not have a matching file", - path.display() + log::debug!("Checking associated files for {}", file.display()); + + let html_path = slides_path.join(&file.with_extension("html").file_name().unwrap()); + assert!( + html_path.exists(), + "Expected {} to exist", + html_path.display() + ); + for ext in ["screen.jpg", "thumb.jpg"] { + let img_path = slides_path.join(file.with_extension(ext).file_name().unwrap()); + assert!( + img_path.exists(), + "Expected {} to exist", + img_path.display() ); + + // TODO: Make sure the screen/thumb is smaller than the original } + } - // There should be a slides dir - let slides_path = path.join("slides"); - assert!(slides_path.is_dir()); - - // No two images should have the same path - let image_set: HashSet<&PathBuf> = files.iter().collect(); - assert_eq!(image_set.len(), files.len()); - - // For each image in the album (including the cover), in slides there should be a: - // - .html - // - .screen. - // - .thumb. - for file in &files { - assert!(slides_path.join(file.with_extension("html")).exists()); - for ext in ["screen.jpg", "thumb.jpg"] { - assert!(slides_path.join(file.with_extension(ext)).exists()); - - // Make sure the screen/thumb is smaller than the original - todo!(); - } - } - - // There shouldn't be any .txt or .md files hanging around - for file in &files { - if let Some(ext) = file.extension() { - assert_ne!(ext, "md"); - assert_ne!(ext, "txt"); - } + // There shouldn't be any .txt or .md files hanging around + for file in &files { + if let Some(ext) = file.extension() { + assert_ne!(ext, "md"); + assert_ne!(ext, "txt"); } } }