Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions examples/background-image-contain.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- end_slide -->

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
32 changes: 32 additions & 0 deletions examples/background-image-cover.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- end_slide -->

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
33 changes: 33 additions & 0 deletions examples/background-image.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- end_slide -->

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
Binary file added examples/bg-square.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/bg-tall.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/bg-wide.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 68 additions & 1 deletion src/presentation/builder/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -203,6 +206,7 @@ impl PresentationBuilder<'_, '_> {
#[serde(rename_all = "snake_case")]
pub(crate) enum CommentCommand {
Alignment(CommentCommandAlignment),
BgImage(BgImageCommand),
Column(usize),
EndSlide,
FontSize(u8),
Expand All @@ -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<raw::BackgroundImageFit>,
pub(crate) opacity: Option<u8>,
}

impl CommentCommand {
/// Generate sample comment strings for all available commands
pub(crate) fn generate_samples() -> Vec<&'static str> {
Expand All @@ -238,6 +249,7 @@ impl CommentCommand {
Alignment => {
vec!["<!-- alignment: left -->", "<!-- alignment: center -->", "<!-- alignment: right -->"]
}
BgImage => vec!["<!-- bg_image: {path: bg.png} -->", "<!-- bg_image: {path: bg.png, fit: cover, opacity: 80} -->"],
Column => vec!["<!-- column: 0 -->"],
EndSlide => vec!["<!-- end_slide -->"],
FontSize => vec!["<!-- font_size: 2 -->"],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = "<!-- bg_image: {path: bg.png, fit: stretch} -->\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 = "<!-- bg_image: {path: bg.png, fit: cover} -->\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 = "<!-- bg_image: {path: bg.png, opacity: 50} -->\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 = "<!-- bg_image: {path: missing.png} -->\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");
Expand Down
130 changes: 127 additions & 3 deletions src/presentation/builder/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -71,6 +79,122 @@ impl PresentationBuilder<'_, '_> {
}
}

pub(crate) struct CoverImageRenderer {
source: DynamicImage,
registry: ImageRegistry,
cache: RefCell<Option<(u16, u16, Image)>>,
}

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<RenderOperation> {
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<RefCell<Option<BackgroundImageOp>>>,
}

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<RenderOperation> {
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}")]
Expand Down
Loading