album page rendering

This commit is contained in:
Nick Pegg 2025-05-06 15:48:18 -07:00
parent f1c007845a
commit 5e54b84f04
14 changed files with 554 additions and 327 deletions

View file

@ -1,9 +1,9 @@
use anyhow::Context;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Deserialize, Debug, PartialEq)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(default)]
pub struct Config {
/// Tuple of how big thumbnails should be - (width, height)

View file

@ -1,14 +1,116 @@
mod album_dir;
pub mod album_dir;
use crate::config::Config;
pub use album_dir::AlbumDir;
use album_dir::{AlbumDir, Image};
use anyhow::anyhow;
use image::imageops::FilterType;
use serde::Serialize;
use std::collections::VecDeque;
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use tera::Tera;
const IMG_RESIZE_FILTER: FilterType = FilterType::Lanczos3;
#[derive(Serialize, Debug)]
struct Breadcrumb {
path: PathBuf,
name: OsString,
}
/// A Tera context for album pages
#[derive(Serialize, Debug)]
struct AlbumContext {
// Path required to get back to the root album
root_path: PathBuf,
// TODO: Do we actualy need the whole albumDir? Probably better off pulling data we need out of
// it.
// album_dir: AlbumDir,
name: OsString,
// TODO: images
// Path to the cover image thumbnail within /slides/. Used when linking to an album from a
// parent album
cover_thumbnail_path: PathBuf,
// list of:
// - relative dir to walk up to root, e.g. "../../.."
// - dir name
breadcrumbs: Vec<Breadcrumb>,
// Immediate children of this album
children: Vec<AlbumContext>,
}
impl TryFrom<&AlbumDir> for AlbumContext {
type Error = anyhow::Error;
fn try_from(album: &AlbumDir) -> anyhow::Result<Self> {
let name: OsString = match album.path.file_name() {
Some(n) => n.to_os_string(),
None => OsString::new(),
};
// Build breadcrumbs
let mut breadcrumbs: Vec<Breadcrumb> = Vec::new();
{
let mut album_path = album.path.clone();
let mut relpath: PathBuf = PathBuf::new();
while album_path.pop() {
let filename: &OsStr = album_path.file_name().unwrap_or(OsStr::new("Home"));
relpath.push("..");
breadcrumbs.push(Breadcrumb {
path: relpath.clone(),
name: filename.into(),
});
}
}
breadcrumbs.reverse();
log::debug!("Crumbs for {}: {breadcrumbs:?}", album.path.display());
// The first breadcrumb path is the relative path to the root album
let root_path = if breadcrumbs.len() > 0 {
breadcrumbs[0].path.clone()
} else {
PathBuf::new()
};
// Pick a cover image thumbnail and build a path to it
let cover_thumbnail_path = match &album.cover {
Some(i) => i.path.clone(),
None => PathBuf::from(""),
};
let children: Vec<AlbumContext> = album
.children
.iter()
.map(|a| AlbumContext::try_from(a))
.collect::<anyhow::Result<Vec<AlbumContext>>>()?;
Ok(AlbumContext {
name,
breadcrumbs,
root_path,
children,
cover_thumbnail_path,
})
}
}
/// A Tera context for slide (individual image) pages
#[derive(Serialize)]
struct SlideContext {
// TODO: Path or String?
root_path: PathBuf,
image: Image,
prev_image: Image,
next_image: Image,
}
pub fn generate(root_path: &PathBuf) -> anyhow::Result<PathBuf> {
log::debug!("Generating album in {}", root_path.display());
let config = Config::from_album(root_path.to_path_buf())?;
@ -18,6 +120,7 @@ pub fn generate(root_path: &PathBuf) -> anyhow::Result<PathBuf> {
env::set_current_dir(root_path)?;
let album = AlbumDir::try_from(root_path)?;
copy_static(&config)?;
generate_images(&config, &album)?;
generate_html(&config, &album)?;
@ -25,6 +128,10 @@ pub fn generate(root_path: &PathBuf) -> anyhow::Result<PathBuf> {
Ok(root_path.join(config.output_dir))
}
fn copy_static(config: &Config) -> anyhow::Result<()> {
Ok(())
}
fn generate_images(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
let output_path = album.path.join(&config.output_dir);
// TODO: use par_iter() ?
@ -32,7 +139,12 @@ fn generate_images(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
for img in album.iter() {
let orig_image = image::open(&img.path)?;
let thumb_path = output_path.join(img.thumb_path()?);
let thumb_path = output_path.join(&img.thumb_path);
log::info!(
"Resizing {} -> {}",
img.path.display(),
thumb_path.display()
);
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
orig_image
.resize(
@ -41,23 +153,54 @@ fn generate_images(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
IMG_RESIZE_FILTER,
)
.save(&thumb_path)?;
log::info!("Resized {} -> {}", img.path.display(), thumb_path.display());
// TODO: resize to screen size
let screen_path = output_path.join(img.screen_path()?);
let screen_path = output_path.join(&img.screen_path);
log::info!(
"Resizing {} -> {}",
img.path.display(),
screen_path.display()
);
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
orig_image
.resize(config.view_size.0, config.view_size.1, IMG_RESIZE_FILTER)
.save(&screen_path)?;
log::info!(
"Resized {} -> {}",
img.path.display(),
screen_path.display()
);
}
Ok(())
}
fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
let output_path = album.path.join(&config.output_dir);
let tera = Tera::new(
album
.path
.join("_templates/*.html")
.to_str()
.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() {
// TODO: create AlbumContext from AlbumDir - TryFrom<&AlbumDir> ?
// TODO: make a function to figure out the cover image and cover_thumbnail_path for each
// albumdir
// TODO: Move breadcrumb generation into From thing?
let html_path = output_path.join(&album.path).join("index.html");
log::info!("Rendering album {}", html_path.display());
let ctx = AlbumContext::try_from(album)?;
log::debug!("Album context: {ctx:?}");
fs::write(
output_path.join(&album.path).join("index.html"),
tera.render("album.html", &tera::Context::from_serialize(&ctx)?)?,
)?;
for child in album.children.iter() {
dir_queue.push_back(&child);
}
}
Ok(())
}

View file

@ -1,6 +1,7 @@
use anyhow::anyhow;
use image::ImageReader;
use serde::Serialize;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::slice::Iter;
@ -10,6 +11,7 @@ use std::slice::Iter;
pub struct AlbumDir {
pub path: PathBuf,
pub images: Vec<Image>,
pub cover: Option<Image>,
// TOOD: Remove the parent reference? Causes a lot of issues
// parent: Option<Box<&'a AlbumDir>>,
pub children: Vec<AlbumDir>,
@ -27,6 +29,7 @@ impl AlbumDir {
/// relative to the root.
fn from_path(p: &Path, root: &Path) -> anyhow::Result<Self> {
let mut images = vec![];
let mut cover: Option<Image> = None;
let mut children = vec![];
let mut description = String::new();
@ -41,6 +44,11 @@ impl AlbumDir {
let _conents = fs::read_to_string(entry_path)?;
// TODO: render markdown
todo!();
} else if filename.to_string_lossy().starts_with("cover") {
cover = Some(Image::new(
entry_path.strip_prefix(&root)?.to_path_buf(),
String::new(),
)?);
} else {
let reader = ImageReader::open(&entry_path)?.with_guessed_format()?;
if reader.format().is_some() {
@ -56,10 +64,10 @@ impl AlbumDir {
todo!();
}
images.push(Image {
path: entry_path.strip_prefix(&root)?.to_path_buf(),
images.push(Image::new(
entry_path.strip_prefix(&root)?.to_path_buf(),
description,
});
)?);
}
}
}
@ -78,11 +86,14 @@ impl AlbumDir {
}
}
// Find all directories in directory and make AlbumDirs out of them,
// but skip dirs known to have interesting stuff
if cover.is_none() && images.len() > 0 {
cover = Some(images[0].clone());
}
Ok(AlbumDir {
path: p.strip_prefix(root)?.to_path_buf(),
images,
cover,
children,
description,
})
@ -141,40 +152,85 @@ pub struct Image {
/// Text description of the image which is displayed below it on the HTML page
pub description: String,
pub thumb_filename: OsString,
pub thumb_path: PathBuf,
pub screen_filename: OsString,
pub screen_path: PathBuf,
pub html_filename: OsString,
pub html_path: PathBuf,
}
impl Image {
pub fn thumb_path(&self) -> anyhow::Result<PathBuf> {
self.slide_path("thumb")
pub fn new(path: PathBuf, description: String) -> anyhow::Result<Self> {
let thumb_filename = Self::slide_filename(&path, "thumb", true)?;
let thumb_path = Self::slide_path(&path, "thumb")?;
let screen_filename = Self::slide_filename(&path, "screen", true)?;
let screen_path = Self::slide_path(&path, "screen")?;
// TODO: add "slides" in html path?
let html_filename = Self::slide_filename(&path, "html", false)?;
let html_path = path.with_extension("html");
Ok(Image {
path,
description,
thumb_filename,
thumb_path,
screen_filename,
screen_path,
html_filename,
html_path,
})
}
pub fn screen_path(&self) -> anyhow::Result<PathBuf> {
self.slide_path("screen")
/// Returns the filename for a given slide type. For example if ext = "thumb" and the current
/// filename is "blah.jpg" this will return "blah.thumb.jpg". If keep_ext if false, it would
/// return "blah.thumb"
fn slide_filename(path: &PathBuf, ext: &str, keep_ext: bool) -> anyhow::Result<OsString> {
let mut new_ext: OsString = ext.into();
if keep_ext {
if let Some(e) = path.extension() {
new_ext = OsString::from(
ext.to_string()
+ "."
+ e.to_str().ok_or(anyhow!(
"Image {} extension is not valid UTF-8",
path.display()
))?,
)
}
}
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()))?;
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(&self, ext: &str) -> anyhow::Result<PathBuf> {
let new_ext = match self.path.extension() {
fn slide_path(path: &PathBuf, ext: &str) -> anyhow::Result<PathBuf> {
let new_ext = match path.extension() {
Some(e) => {
ext.to_string()
+ "."
+ e.to_str().ok_or(anyhow!(
"Image {} extension is not valid UTF-8",
self.path.display()
path.display()
))?
}
None => ext.to_string(),
};
let new_path = self.path.with_extension(new_ext);
let new_path = path.with_extension(new_ext);
let new_name = new_path
.file_name()
.ok_or(anyhow!("Image {} missing a file name", self.path.display()))?;
let parent = self
.path
.ok_or(anyhow!("Image {} missing a file name", path.display()))?;
let parent = path
.parent()
.ok_or(anyhow!("Image {} has no parent dir", self.path.display()))?;
.ok_or(anyhow!("Image {} has no parent dir", path.display()))?;
Ok(parent.join("slides").join(new_name))
}
@ -190,15 +246,10 @@ mod tests {
let mut ad = AlbumDir {
path: "".into(),
description: "".to_string(),
cover: Some(Image::new("foo".into(), "".to_string()).unwrap()),
images: vec![
Image {
path: "foo".into(),
description: "".to_string(),
},
Image {
path: "bar".into(),
description: "".to_string(),
},
Image::new("foo".into(), "".to_string()).unwrap(),
Image::new("bar".into(), "".to_string()).unwrap(),
],
children: vec![],
};
@ -206,29 +257,27 @@ mod tests {
ad.children.push(AlbumDir {
path: "subdir".into(),
description: "".to_string(),
cover: Some(Image::new("subdir/foo".into(), "".to_string()).unwrap()),
images: vec![
Image {
path: "subdir/foo".into(),
description: "".to_string(),
},
Image {
path: "subdir/bar".into(),
description: "".to_string(),
},
Image::new("subdir/foo".into(), "".to_string()).unwrap(),
Image::new("subdir/bar".into(), "".to_string()).unwrap(),
],
children: vec![AlbumDir {
path: "subdir/deeper_subdir".into(),
description: "".to_string(),
images: vec![Image {
path: "subdir/deeper_subdir/image.jpg".into(),
description: "".to_string(),
}],
cover: Some(
Image::new("subdir/deeper_subdir/image.jpg".into(), "".to_string()).unwrap(),
),
images: vec![
Image::new("subdir/deeper_subdir/image.jpg".into(), String::new()).unwrap(),
],
children: vec![],
}],
});
// A child album with no images
ad.children.push(AlbumDir {
description: "".to_string(),
cover: None,
path: "another_subdir".into(),
images: vec![],
children: vec![],
@ -247,16 +296,13 @@ mod tests {
#[test]
fn image_paths() {
let img = Image {
path: PathBuf::from("foo/bar/image.jpg"),
description: String::new(),
};
let img = Image::new(PathBuf::from("foo/bar/image.jpg"), String::new()).unwrap();
assert_eq!(
img.thumb_path().unwrap(),
img.thumb_path,
PathBuf::from("foo/bar/slides/image.thumb.jpg")
);
assert_eq!(
img.screen_path().unwrap(),
img.screen_path,
PathBuf::from("foo/bar/slides/image.screen.jpg")
);
}