diff --git a/examples/background-image-contain.md b/examples/background-image-contain.md new file mode 100644 index 00000000..2268bc4a --- /dev/null +++ b/examples/background-image-contain.md @@ -0,0 +1,33 @@ +--- +title: "fit: contain" +sub_title: Background image — contain mode +author: presenterm +theme: + override: + default: + colors: + foreground: "e6e6e6" + background: "040312" + background_image: + path: bg-tall.png + opacity: 80 + fit: contain +--- + +contain — tall image (540x960) at 80% opacity +--- + +The image is scaled to **fit entirely** within the terminal while keeping +its aspect ratio. The background color fills the remaining space. + +Circles should look **round** (not distorted). The full image is visible. + + + +Details +--- + +* The tall image fits within the terminal height +* Dark bars (the background color "040312") appear on the sides +* The image is centered both horizontally and vertically +* Try resizing — the image re-centers and rescales to always fit diff --git a/examples/background-image-cover.md b/examples/background-image-cover.md new file mode 100644 index 00000000..1f8c2ad2 --- /dev/null +++ b/examples/background-image-cover.md @@ -0,0 +1,32 @@ +--- +title: "fit: cover" +sub_title: Background image — cover mode +author: presenterm +theme: + override: + default: + colors: + foreground: "e6e6e6" + background: "040312" + background_image: + path: bg-square.png + fit: cover +--- + +cover — square image (800x800) +--- + +The image is scaled to **cover** the full terminal while keeping its +aspect ratio. Part of the image may be clipped. + +Circles should look **round** (not distorted). + + + +Details +--- + +* The square image scales up until it covers the full terminal +* Edges are cropped evenly from all sides +* Try resizing the terminal — the image re-renders to always cover +* The background **color** shows through any uncovered area diff --git a/examples/background-image.md b/examples/background-image.md new file mode 100644 index 00000000..4bdfed99 --- /dev/null +++ b/examples/background-image.md @@ -0,0 +1,33 @@ +--- +title: "fit: stretch" +sub_title: Background image — stretch mode (default) +author: presenterm +theme: + override: + default: + colors: + foreground: "e6e6e6" + background: "040312" + background_image: + path: bg-wide.png + fit: stretch +--- + +stretch — wide image (1920x1080) +--- + +The image is **stretched** to fill the exact terminal dimensions, +ignoring aspect ratio. + +Circles may appear slightly **distorted** because the image is forced +to match the exact terminal dimensions. + + + +Details +--- + +* The image fills every cell of the terminal — no gaps, no clipping +* Aspect ratio is not preserved, so shapes may be distorted +* This is the simplest mode and the default when `fit` is omitted +* Try resizing — the distortion changes as the terminal shape changes diff --git a/examples/bg-square.png b/examples/bg-square.png new file mode 100644 index 00000000..74b3a830 Binary files /dev/null and b/examples/bg-square.png differ diff --git a/examples/bg-tall.png b/examples/bg-tall.png new file mode 100644 index 00000000..cf8f0913 Binary files /dev/null and b/examples/bg-tall.png differ diff --git a/examples/bg-wide.png b/examples/bg-wide.png new file mode 100644 index 00000000..926ff5d3 Binary files /dev/null and b/examples/bg-wide.png differ diff --git a/src/presentation/builder/comment.rs b/src/presentation/builder/comment.rs index afe2b5ef..871f65d8 100644 --- a/src/presentation/builder/comment.rs +++ b/src/presentation/builder/comment.rs @@ -2,7 +2,7 @@ use crate::{ markdown::elements::{MarkdownElement, SourcePosition}, presentation::builder::{BuildResult, LayoutState, PresentationBuilder, error::InvalidPresentation}, render::operation::{LayoutGrid, RenderOperation}, - theme::{Alignment, ElementType}, + theme::{Alignment, ElementType, raw}, }; use serde::Deserialize; use std::{fmt, num::NonZeroU8, path::PathBuf, str::FromStr}; @@ -40,6 +40,9 @@ impl PresentationBuilder<'_, '_> { ) -> BuildResult { match command { CommentCommand::Pause => self.push_pause(), + CommentCommand::BgImage(cmd) => { + self.set_slide_background_image(cmd, source_position)?; + } CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::NewLine => self.push_line_breaks(self.slide_font_size() as usize), CommentCommand::NewLines(count) => { @@ -203,6 +206,7 @@ impl PresentationBuilder<'_, '_> { #[serde(rename_all = "snake_case")] pub(crate) enum CommentCommand { Alignment(CommentCommandAlignment), + BgImage(BgImageCommand), Column(usize), EndSlide, FontSize(u8), @@ -226,6 +230,13 @@ pub(crate) enum CommentCommand { Comment(String), } +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct BgImageCommand { + pub(crate) path: PathBuf, + pub(crate) fit: Option, + pub(crate) opacity: Option, +} + impl CommentCommand { /// Generate sample comment strings for all available commands pub(crate) fn generate_samples() -> Vec<&'static str> { @@ -238,6 +249,7 @@ impl CommentCommand { Alignment => { vec!["", "", ""] } + BgImage => vec!["", ""], Column => vec![""], EndSlide => vec![""], FontSize => vec![""], @@ -321,6 +333,8 @@ mod tests { #[case::incremental_lists("new_line", CommentCommand::NewLine)] #[case::incremental_lists("newline", CommentCommand::NewLine)] #[case::comment("comment: This is a user comment", CommentCommand::Comment("This is a user comment".into()))] + #[case::bg_image("bg_image: {path: bg.png}", CommentCommand::BgImage(BgImageCommand { path: "bg.png".into(), fit: None, opacity: None }))] + #[case::bg_image_with_options("bg_image: {path: bg.png, fit: contain, opacity: 80}", CommentCommand::BgImage(BgImageCommand { path: "bg.png".into(), fit: Some(raw::BackgroundImageFit::Contain), opacity: Some(80) }))] fn command_formatting(#[case] input: &str, #[case] expected: CommentCommand) { let parsed: CommentCommand = input.parse().expect("deserialization failed"); assert_eq!(parsed, expected); @@ -738,6 +752,59 @@ hola assert_eq!(lines, expected); } + fn write_test_png(dir: &std::path::Path, name: &str) { + let image = DynamicImage::new_rgba8(2, 2); + let file = fs::File::create(dir.join(name)).expect("failed to create image file"); + let mut buffer = BufWriter::new(file); + PngEncoder::new(&mut buffer) + .write_image(image.as_bytes(), 2, 2, image.color().into()) + .expect("failed to encode png"); + } + + #[test] + fn bg_image_comment_loads_png() { + let dir = tempdir().expect("failed to create tempdir"); + write_test_png(dir.path(), "bg.png"); + + let input = "\nhello"; + Test::new(input).resources_path(dir.path()).build(); + } + + #[test] + fn bg_image_comment_cover_mode() { + let dir = tempdir().expect("failed to create tempdir"); + write_test_png(dir.path(), "bg.png"); + + let input = "\nhello"; + Test::new(input).resources_path(dir.path()).build(); + } + + #[test] + fn bg_image_comment_with_opacity() { + let dir = tempdir().expect("failed to create tempdir"); + write_test_png(dir.path(), "bg.png"); + + let input = "\nhello"; + Test::new(input).resources_path(dir.path()).build(); + } + + #[test] + fn bg_image_comment_missing_file() { + let dir = tempdir().expect("failed to create tempdir"); + + let input = "\nhello"; + Test::new(input).resources_path(dir.path()).expect_invalid(); + } + + #[test] + fn theme_background_image_stretch() { + let dir = tempdir().expect("failed to create tempdir"); + write_test_png(dir.path(), "bg.png"); + + let input = "---\ntheme:\n override:\n default:\n background_image:\n path: bg.png\n fit: stretch\n---\nhello"; + Test::new(input).resources_path(dir.path()).build(); + } + #[test] fn include() { let dir = tempdir().expect("failed to created tempdir"); diff --git a/src/presentation/builder/images.rs b/src/presentation/builder/images.rs index 52b40fba..a5fe7149 100644 --- a/src/presentation/builder/images.rs +++ b/src/presentation/builder/images.rs @@ -4,10 +4,18 @@ use crate::{ BuildResult, PresentationBuilder, error::{BuildError, InvalidPresentation}, }, - render::operation::{ImageRenderProperties, ImageSize, RenderOperation}, - terminal::image::Image, + render::{ + operation::{AsRenderOperations, ImageRenderProperties, ImageSize, RenderOperation}, + properties::WindowSize, + }, + terminal::image::{ + Image, + printer::{ImageRegistry, ImageSpec}, + }, + theme::raw::BackgroundImageFit, }; -use std::path::PathBuf; +use image::DynamicImage; +use std::{cell::RefCell, fmt, path::PathBuf, rc::Rc}; impl PresentationBuilder<'_, '_> { pub(crate) fn push_image_from_path( @@ -71,6 +79,122 @@ impl PresentationBuilder<'_, '_> { } } +pub(crate) struct CoverImageRenderer { + source: DynamicImage, + registry: ImageRegistry, + cache: RefCell>, +} + +impl CoverImageRenderer { + fn new(source: DynamicImage, registry: ImageRegistry) -> Self { + Self { source, registry, cache: RefCell::new(None) } + } + + fn crop_to_aspect(&self, dimensions: &WindowSize) -> DynamicImage { + let px_per_col = if dimensions.width > 0 { dimensions.pixels_per_column() } else { 8.0 }; + let px_per_row = if dimensions.height > 0 { dimensions.pixels_per_row() } else { 16.0 }; + + let screen_w = dimensions.columns as f64 * px_per_col; + let screen_h = dimensions.rows as f64 * px_per_row; + + let img_w = self.source.width() as f64; + let img_h = self.source.height() as f64; + + let screen_ratio = screen_w / screen_h; + let img_ratio = img_w / img_h; + + let (crop_w, crop_h) = if img_ratio > screen_ratio { + ((img_h * screen_ratio).round(), img_h) + } else { + (img_w, (img_w / screen_ratio).round()) + }; + + let x = ((img_w - crop_w) / 2.0).round() as u32; + let y = ((img_h - crop_h) / 2.0).round() as u32; + self.source.crop_imm(x, y, crop_w as u32, crop_h as u32) + } +} + +impl fmt::Debug for CoverImageRenderer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CoverImageRenderer").finish() + } +} + +impl AsRenderOperations for CoverImageRenderer { + fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { + let cols = dimensions.columns; + let rows = dimensions.rows; + + let mut cache = self.cache.borrow_mut(); + let image = match &*cache { + Some((c, r, img)) if *c == cols && *r == rows => img.clone(), + _ => { + let cropped = self.crop_to_aspect(dimensions); + let Ok(img) = self.registry.register(ImageSpec::Generated(cropped)) else { + return Vec::new(); + }; + *cache = Some((cols, rows, img.clone())); + img + } + }; + + vec![RenderOperation::RenderImage(image, ImageRenderProperties::background(ImageSize::Stretch))] + } +} + +#[derive(Clone)] +pub(crate) struct BackgroundImageSlot { + inner: Rc>>, +} + +enum BackgroundImageOp { + Static(Image, ImageSize), + Cover(CoverImageRenderer), +} + +impl fmt::Debug for BackgroundImageOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Static(img, size) => f.debug_tuple("Static").field(img).field(size).finish(), + Self::Cover(_) => f.write_str("Cover"), + } + } +} + +impl BackgroundImageSlot { + pub(crate) fn new() -> Self { + Self { inner: Rc::new(RefCell::new(None)) } + } + + pub(crate) fn set_static(&self, image: Image, fit: BackgroundImageFit) { + *self.inner.borrow_mut() = Some(BackgroundImageOp::Static(image, fit.into())); + } + + pub(crate) fn set_cover(&self, source: DynamicImage, registry: ImageRegistry) { + let renderer = CoverImageRenderer::new(source, registry); + *self.inner.borrow_mut() = Some(BackgroundImageOp::Cover(renderer)); + } +} + +impl fmt::Debug for BackgroundImageSlot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BackgroundImageSlot").finish() + } +} + +impl AsRenderOperations for BackgroundImageSlot { + fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { + match &*self.inner.borrow() { + None => Vec::new(), + Some(BackgroundImageOp::Static(image, size)) => { + vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::background(size.clone()))] + } + Some(BackgroundImageOp::Cover(renderer)) => renderer.as_render_operations(dimensions), + } + } +} + #[derive(thiserror::Error, Debug)] pub(crate) enum ImageAttributeError { #[error("invalid width: {0}")] diff --git a/src/presentation/builder/mod.rs b/src/presentation/builder/mod.rs index 50d92a57..b40b6693 100644 --- a/src/presentation/builder/mod.rs +++ b/src/presentation/builder/mod.rs @@ -350,18 +350,64 @@ impl<'a, 'b> PresentationBuilder<'a, 'b> { let style = self.theme.default_style.style; self.set_colors(style.colors); + self.chunk_operations.push(RenderOperation::ClearScreen); + let bg_slot = images::BackgroundImageSlot::new(); + if let Some(bg) = &self.theme.default_style.background_image { + if let Some(source) = &bg.source { + bg_slot.set_cover(source.clone(), self.image_registry.clone()); + } else { + bg_slot.set_static(bg.image.clone(), bg.fit.clone()); + } + } + self.slide_state.background_image_slot = Some(bg_slot.clone()); + self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(bg_slot))); + let footer_height = self.theme.footer.height(); - self.chunk_operations.extend([ - RenderOperation::ClearScreen, - RenderOperation::ApplyMargin(MarginProperties { - horizontal: self.theme.default_style.margin, - top: 0, - bottom: footer_height, - }), - ]); + self.chunk_operations.push(RenderOperation::ApplyMargin(MarginProperties { + horizontal: self.theme.default_style.margin, + top: 0, + bottom: footer_height, + })); self.push_line_break(); } + fn set_slide_background_image( + &mut self, + cmd: comment::BgImageCommand, + source_position: SourcePosition, + ) -> BuildResult { + let Some(slot) = &self.slide_state.background_image_slot else { + return Ok(()); + }; + let base_path = self.resource_base_path(); + let opacity = cmd.opacity.unwrap_or(100).min(100); + let fit = cmd.fit.unwrap_or_default(); + let is_cover = matches!(fit, raw::BackgroundImageFit::Cover); + let load_err = |e: &dyn std::fmt::Display| { + self.invalid_presentation( + source_position, + InvalidPresentation::LoadImage { path: cmd.path.clone(), error: e.to_string() }, + ) + }; + if opacity >= 100 && !is_cover { + let image = self.resources.image(&cmd.path, &base_path).map_err(|e| load_err(&e))?; + slot.set_static(image, fit); + return Ok(()); + } + let path = self.resources.resolve_path(&cmd.path, &base_path); + let mut dynamic = self.resources.load_dynamic_image(&path).map_err(|e| load_err(&e))?; + if opacity < 100 { + crate::terminal::image::apply_opacity(&mut dynamic, opacity); + } + if is_cover { + slot.set_cover(dynamic, self.image_registry.clone()); + } else { + let image = self.image_registry.register(ImageSpec::Generated(dynamic)).map_err(|e| load_err(&e))?; + slot.set_static(image, fit); + } + Ok(()) + } + fn process_element_for_presentation_mode(&mut self, element: MarkdownElement) -> BuildResult { let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. }); match element { @@ -608,6 +654,7 @@ struct SlideState { alignment: Option, skip_slide: bool, last_layout_comment: Option, + background_image_slot: Option, } #[derive(Clone, Debug, Default)] diff --git a/src/render/engine.rs b/src/render/engine.rs index de867a01..41aa45f5 100644 --- a/src/render/engine.rs +++ b/src/render/engine.rs @@ -264,27 +264,45 @@ where CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column }; let (width, height) = image.image().dimensions(); - let (columns, rows) = match properties.size { + let (columns, rows, cursor_override) = match properties.size { ImageSize::ShrinkIfNeeded => { let image_scale = self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor); - (image_scale.columns, image_scale.rows) + (image_scale.columns, image_scale.rows, None) } - ImageSize::Specific(columns, rows) => (columns, rows), + ImageSize::Specific(columns, rows) => (columns, rows, None), ImageSize::WidthScaled { ratio } => { let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16; let dimensions = rect.dimensions.shrink_columns(extra_columns); let image_scale = self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor); - (image_scale.columns, image_scale.rows) + (image_scale.columns, image_scale.rows, None) + } + ImageSize::Stretch => (rect.dimensions.columns, rect.dimensions.rows, None), + ImageSize::Cover => { + let (cols, rows) = Self::scale_to_fit(width, height, &rect.dimensions, f64::max); + (cols, rows, None) + } + ImageSize::Contain => { + let (cols, rows) = Self::scale_to_fit(width, height, &rect.dimensions, f64::min); + let col_offset = (rect.dimensions.columns.saturating_sub(cols)) / 2; + let row_offset = (rect.dimensions.rows.saturating_sub(rows)) / 2; + let cursor = CursorPosition { + column: starting_cursor.column + col_offset, + row: starting_cursor.row + row_offset, + }; + (cols, rows, Some(cursor)) } }; - let cursor = match &properties.position { + let cursor = cursor_override.unwrap_or_else(|| match &properties.position { ImagePosition::Cursor => starting_cursor.clone(), ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor), ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor), - }; + }); self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?; + if cursor.row != starting_cursor.row { + self.terminal.execute(&TerminalCommand::MoveToRow(rect.start_row + cursor.row))?; + } let options = PrintOptions { columns, @@ -303,6 +321,30 @@ where self.apply_colors() } + /// Scale image to cell dimensions using the given strategy to pick between horizontal and + /// vertical scale factors. `f64::max` gives cover semantics, `f64::min` gives contain. + fn scale_to_fit( + image_width: u32, + image_height: u32, + dimensions: &WindowSize, + pick_scale: fn(f64, f64) -> f64, + ) -> (u16, u16) { + // Pixels per cell; fall back to common 8x16 when the terminal doesn't report pixel size. + let px_per_col = if dimensions.width > 0 { dimensions.pixels_per_column() } else { 8.0 }; + let px_per_row = if dimensions.height > 0 { dimensions.pixels_per_row() } else { 16.0 }; + + let natural_cols = image_width as f64 / px_per_col; + let natural_rows = image_height as f64 / px_per_row; + + let scale_x = dimensions.columns as f64 / natural_cols; + let scale_y = dimensions.rows as f64 / natural_rows; + let scale = pick_scale(scale_x, scale_y); + + let cols = (natural_cols * scale).round().max(1.0) as u16; + let rows = (natural_rows * scale).round().max(1.0) as u16; + (cols, rows) + } + fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition { let start_column = window.columns / 2 - (columns / 2); let start_column = start_column + cursor.column; @@ -959,7 +1001,96 @@ mod tests { assert_eq!(ops, expected); } - // same as the above but center it + #[test] + fn stretch_image() { + let image = DynamicImage::new(40, 10, ColorType::Rgba8); + let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); + let properties = ImageRenderProperties { + z_index: -1, + size: ImageSize::Stretch, + restore_cursor: true, + background_color: None, + position: ImagePosition::Cursor, + }; + let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); + let expected = [ + Instruction::MoveTo(40, 45), + Instruction::MoveToColumn(40), + Instruction::PrintImage(PrintOptions { + columns: 20, + rows: 10, + z_index: -1, + background_color: None, + column_width: 2, + row_height: 2, + }), + Instruction::MoveTo(40, 45), + ]; + assert_eq!(ops, expected); + } + + #[test] + fn cover_image() { + // 40x10 px image → natural 20x5 cells (at 2px/cell). Cover a 20x10 area: + // scale_x=1.0, scale_y=2.0, pick max → 40x10 cells. + let image = DynamicImage::new(40, 10, ColorType::Rgba8); + let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); + let properties = ImageRenderProperties { + z_index: -1, + size: ImageSize::Cover, + restore_cursor: true, + background_color: None, + position: ImagePosition::Cursor, + }; + let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); + let expected = [ + Instruction::MoveTo(40, 45), + Instruction::MoveToColumn(40), + Instruction::PrintImage(PrintOptions { + columns: 40, + rows: 10, + z_index: -1, + background_color: None, + column_width: 2, + row_height: 2, + }), + Instruction::MoveTo(40, 45), + ]; + assert_eq!(ops, expected); + } + + #[test] + fn contain_image() { + // 40x10 px → natural 20x5 cells. Contain in 20x10: scale_x=1.0, scale_y=2.0, + // pick min → 20x5 cells. Centered: col_offset=0, row_offset=(10-5)/2=2. + let image = DynamicImage::new(40, 10, ColorType::Rgba8); + let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); + let properties = ImageRenderProperties { + z_index: -1, + size: ImageSize::Contain, + restore_cursor: true, + background_color: None, + position: ImagePosition::Cursor, + }; + let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); + let expected = [ + Instruction::MoveTo(40, 45), + Instruction::MoveToColumn(40), + // row offset: start_row(45) + cursor.row(0) + offset(2) = 47 + Instruction::MoveToRow(47), + Instruction::PrintImage(PrintOptions { + columns: 20, + rows: 5, + z_index: -1, + background_color: None, + column_width: 2, + row_height: 2, + }), + Instruction::MoveTo(40, 45), + ]; + assert_eq!(ops, expected); + } + #[rstest] fn restore_cursor_after_image() { let image = DynamicImage::new(2, 2, ColorType::Rgba8); diff --git a/src/render/operation.rs b/src/render/operation.rs index 66a812eb..9536d5ba 100644 --- a/src/render/operation.rs +++ b/src/render/operation.rs @@ -5,7 +5,7 @@ use crate::{ text_style::{Color, Colors, TextStyle}, }, terminal::image::Image, - theme::{Alignment, Margin}, + theme::{Alignment, Margin, raw::BackgroundImageFit}, }; use std::{ fmt::Debug, @@ -14,6 +14,7 @@ use std::{ }; const DEFAULT_IMAGE_Z_INDEX: i32 = -2; +const BACKGROUND_IMAGE_Z_INDEX: i32 = -3; /// A line of preformatted text to be rendered. #[derive(Clone, Debug, PartialEq)] @@ -130,6 +131,18 @@ impl Default for ImageRenderProperties { } } +impl ImageRenderProperties { + pub(crate) fn background(size: ImageSize) -> Self { + Self { + z_index: BACKGROUND_IMAGE_Z_INDEX, + size, + restore_cursor: true, + background_color: None, + position: ImagePosition::Cursor, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub(crate) enum ImagePosition { Cursor, @@ -146,6 +159,19 @@ pub(crate) enum ImageSize { WidthScaled { ratio: f64, }, + Stretch, + Cover, + Contain, +} + +impl From for ImageSize { + fn from(fit: BackgroundImageFit) -> Self { + match fit { + BackgroundImageFit::Stretch => Self::Stretch, + BackgroundImageFit::Cover => Self::Cover, + BackgroundImageFit::Contain => Self::Contain, + } + } } /// Slide properties, set on initialization. diff --git a/src/resource.rs b/src/resource.rs index b92b857e..74bf76d7 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -92,6 +92,29 @@ impl Resources { Ok(image) } + /// Resolve a theme image path, falling back to the themes directory. + pub(crate) fn resolve_theme_image_path>(&self, path: P) -> PathBuf { + let presentation_path = self.resolve_path(&path, &ResourceBasePath::Presentation); + if presentation_path.exists() { + return presentation_path; + } + let inner = self.inner.borrow(); + inner.themes_path.join(path) + } + + pub(crate) fn load_dynamic_image(&self, path: &Path) -> Result { + image::open(path).map_err(RegisterImageError::Image) + } + + /// Register a generated (in-memory) image. + pub(crate) fn register_generated_image( + &self, + image: image::DynamicImage, + ) -> Result { + let inner = self.inner.borrow(); + inner.image_registry.register(ImageSpec::Generated(image)) + } + /// Get the theme at the given path. pub(crate) fn theme>(&self, path: P) -> Result { let mut inner = self.inner.borrow_mut(); diff --git a/src/terminal/image/mod.rs b/src/terminal/image/mod.rs index e6f5af6e..192e7f1e 100644 --- a/src/terminal/image/mod.rs +++ b/src/terminal/image/mod.rs @@ -74,3 +74,18 @@ pub(crate) enum ImageSource { Filesystem(PathBuf), Generated, } + +pub(crate) fn apply_opacity(image: &mut DynamicImage, opacity: u8) { + let factor = opacity as f32 / 100.0; + if let Some(rgba) = image.as_mut_rgba8() { + for pixel in rgba.pixels_mut() { + pixel[3] = (pixel[3] as f32 * factor).round() as u8; + } + } else { + let mut rgba = image.to_rgba8(); + for pixel in rgba.pixels_mut() { + pixel[3] = (pixel[3] as f32 * factor).round() as u8; + } + *image = DynamicImage::from(rgba); + } +} diff --git a/src/terminal/image/printer.rs b/src/terminal/image/printer.rs index b312ecbb..5d8673d2 100644 --- a/src/terminal/image/printer.rs +++ b/src/terminal/image/printer.rs @@ -181,7 +181,6 @@ impl ImageRegistry { let (source, cache_key) = match &spec { ImageSpec::Generated(_) => (ImageSource::Generated, None), ImageSpec::Filesystem(path) => { - // Return if already cached if let Some(image) = images.get(path) { return Ok(image.clone()); } diff --git a/src/theme/clean.rs b/src/theme/clean.rs index 02b69511..89fd84f6 100644 --- a/src/theme/clean.rs +++ b/src/theme/clean.rs @@ -88,7 +88,7 @@ impl PresentationTheme { } = raw; let palette = ColorPalette::try_from(palette)?; - let default_style = DefaultStyle::new(default_style, &palette)?; + let default_style = DefaultStyle::new(default_style, &palette, resources)?; Ok(Self { slide_title: SlideTitleStyle::new(slide_title, &palette, options)?, code: CodeBlockStyle::new(code), @@ -149,6 +149,9 @@ pub(crate) enum ProcessingThemeError { #[error("invalid footer image: {0}")] FooterImage(RegisterImageError), + + #[error("invalid background image: {0}")] + BackgroundImage(RegisterImageError), } #[derive(Clone, Debug)] @@ -456,20 +459,58 @@ impl AuthorStyle { } } +#[derive(Clone, Debug)] +pub(crate) struct BackgroundImage { + pub(crate) image: Image, + pub(crate) fit: raw::BackgroundImageFit, + pub(crate) source: Option, +} + #[derive(Clone, Debug, Default)] pub(crate) struct DefaultStyle { pub(crate) margin: Margin, pub(crate) style: TextStyle, pub(crate) alignment: Alignment, + pub(crate) background_image: Option, } impl DefaultStyle { - fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result { - let raw::DefaultStyle { margin, colors, alignment } = raw; + fn new( + raw: &raw::DefaultStyle, + palette: &ColorPalette, + resources: &Resources, + ) -> Result { + let raw::DefaultStyle { margin, colors, alignment, background_image } = raw; let margin = margin.unwrap_or_default(); let style = TextStyle::colored(colors.resolve(palette)?); let alignment = alignment.clone().unwrap_or_default().into(); - Ok(Self { margin, style, alignment }) + let background_image = background_image + .as_ref() + .map(|bg| Self::load_background_image(bg, resources)) + .transpose()?; + Ok(Self { margin, style, alignment, background_image }) + } + + fn load_background_image( + bg: &raw::BackgroundImage, + resources: &Resources, + ) -> Result { + let opacity = bg.opacity.unwrap_or(100).min(100); + let fit = bg.fit.clone().unwrap_or_default(); + let is_cover = matches!(fit, raw::BackgroundImageFit::Cover); + if opacity >= 100 && !is_cover { + let image = resources.theme_image(&bg.path).map_err(ProcessingThemeError::BackgroundImage)?; + return Ok(BackgroundImage { image, fit, source: None }); + } + let path = resources.resolve_theme_image_path(&bg.path); + let mut dynamic = + resources.load_dynamic_image(&path).map_err(ProcessingThemeError::BackgroundImage)?; + if opacity < 100 { + crate::terminal::image::apply_opacity(&mut dynamic, opacity); + } + let source = if is_cover { Some(dynamic.clone()) } else { None }; + let image = resources.register_generated_image(dynamic).map_err(ProcessingThemeError::BackgroundImage)?; + Ok(BackgroundImage { image, fit, source }) } } diff --git a/src/theme/raw.rs b/src/theme/raw.rs index 205ee699..a96a5ec0 100644 --- a/src/theme/raw.rs +++ b/src/theme/raw.rs @@ -358,6 +358,40 @@ pub(crate) struct DefaultStyle { /// The alignment for all elements. #[serde(flatten, default)] pub(crate) alignment: Option, + + /// The background image to display behind slide content. + #[serde(default)] + pub(crate) background_image: Option, +} + +/// The background image configuration. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct BackgroundImage { + /// The path to the image file. + pub(crate) path: PathBuf, + + /// The opacity of the background image (0-100). + #[serde(default)] + pub(crate) opacity: Option, + + /// How the image should fit the screen. + #[serde(default)] + pub(crate) fit: Option, +} + +/// How a background image should fit the screen. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum BackgroundImageFit { + /// Scale to cover the entire screen, maintaining aspect ratio. + #[default] + Cover, + + /// Scale to fit within the screen, maintaining aspect ratio. + Contain, + + /// Stretch to fill the screen exactly, ignoring aspect ratio. + Stretch, } /// The column layout style.