From ba46d7be9e60828266c3887aea707f6dab3adce6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 3 Oct 2026 20:00:05 +1300 Subject: [PATCH] Add slide background images with configurable sizing. --- components/presently/Presently.js | 2 +- .../presently/Presently/SlideRendering.js | 17 +++- components/presently/test/SlideRendering.js | 81 ++++++++++++++++++- context/getting-started.md | 30 +++++++ guides/getting-started/readme.md | 30 +++++++ lib/presently/slide.rb | 43 +++++++--- lib/presently/slide_renderer.rb | 9 ++- public/_components/.manifest.json | 6 +- .../@socketry/presently/Presently.js | 2 +- .../presently/Presently/SlideRendering.js | 17 +++- public/_static/slides.css | 5 +- public/export.js | 9 ++- test/presently/slide.rb | 59 ++++++++++++++ test/presently/slide_renderer.rb | 30 +++++++ 14 files changed, 312 insertions(+), 28 deletions(-) diff --git a/components/presently/Presently.js b/components/presently/Presently.js index 8002eec..f401c17 100644 --- a/components/presently/Presently.js +++ b/components/presently/Presently.js @@ -1,4 +1,4 @@ export {Slide, SlideBuilder, SlideContext, SlideElements} from './Presently/Slide.js'; export {applyCodeFocus} from './Presently/CodeFocus.js'; -export {SlideRendering} from './Presently/SlideRendering.js'; +export {SlideRendering, prepareSlideBackgrounds} from './Presently/SlideRendering.js'; export {runAllSlideScripts, runScript} from './Presently/Scripts.js'; diff --git a/components/presently/Presently/SlideRendering.js b/components/presently/Presently/SlideRendering.js index 71b62a3..bfe5b2f 100644 --- a/components/presently/Presently/SlideRendering.js +++ b/components/presently/Presently/SlideRendering.js @@ -4,8 +4,19 @@ import {runScript} from './Scripts.js'; const SLIDE_CHANGE_EVENT = 'presently:slide:change'; -async function prepareSlides() { - await Syntax.highlight(); +// Wait for background images to decode before capturing a transition or export. +// A missing or invalid image must not prevent the slides from becoming ready. +export async function prepareSlideBackgrounds(root = document) { + const urls = new Set(Array.from(root.querySelectorAll('.slide-surface[data-background]'), surface => surface.dataset.background)); + await Promise.all(Array.from(urls, async url => { + const image = new Image(); + image.src = url; + await image.decode().catch(() => {}); + })); +} + +async function prepareSlides(view) { + await Promise.all([Syntax.highlight(), prepareSlideBackgrounds(view)]); await applyCodeFocus(); } @@ -42,7 +53,7 @@ export class SlideRendering { if (slide) this.#slides.push(slide); }); - await prepareSlides(); + await prepareSlides(this.#view); return !this.#disposed; } diff --git a/components/presently/test/SlideRendering.js b/components/presently/test/SlideRendering.js index 650decf..b2ce333 100644 --- a/components/presently/test/SlideRendering.js +++ b/components/presently/test/SlideRendering.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import Syntax from '@socketry/syntax'; -import {SlideRendering} from '../Presently/SlideRendering.js'; +import {SlideRendering, prepareSlideBackgrounds} from '../Presently/SlideRendering.js'; globalThis.window = globalThis; @@ -29,14 +29,93 @@ class View extends EventTarget { super(); this.id = id; this.slides = slides; + this.backgrounds = []; } querySelectorAll(selector) { + if (selector === '.slide-surface[data-background]') return this.backgrounds; assert.equal(selector, '.slide'); return this.slides; } } +test('background preparation waits for decoding, deduplicates URLs, and tolerates missing images', async () => { + const images = []; + const originalImage = globalThis.Image; + globalThis.Image = class { + constructor() { + images.push(this); + } + decode() { + return new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + }; + + try { + const view = new View('backgrounds', []); + view.backgrounds = ['image.jpg', 'missing.jpg', 'image.jpg'].map(background => ({dataset: {background}})); + let ready = false; + const prepared = prepareSlideBackgrounds(view).then(() => ready = true); + assert.deepEqual(images.map(image => image.src), ['image.jpg', 'missing.jpg']); + images[0].resolve(); + await Promise.resolve(); + assert.equal(ready, false); + images[1].reject(new Error('Missing image')); + await prepared; + assert.equal(ready, true); + } finally { + globalThis.Image = originalImage; + } +}); + +test('a transition waits for its background before capturing the incoming slide', async () => { + const originalHighlight = Syntax.highlight; + const originalImage = globalThis.Image; + Syntax.highlight = async () => {}; + let decoded; + globalThis.Image = class { + decode() { + return new Promise(resolve => decoded = resolve); + } + }; + let transition; + globalThis.document = { + documentElement: {dataset: {}}, + querySelectorAll: () => [], + startViewTransition: callback => transition = pendingTransition(callback), + }; + + try { + const view = new View('background', []); + const rendering = new SlideRendering(view, {transition: 'fade'}); + let changes = 0; + view.addEventListener('presently:slide:change', () => changes += 1); + const rendered = rendering.render(() => { + view.backgrounds = [{dataset: {background: 'image.jpg'}}]; + }); + transition.update(); + let captured = false; + transition.updateCallbackDone.then(() => captured = true); + await Promise.resolve(); + assert.equal(captured, false); + assert.equal(changes, 0); + decoded(); + await transition.updateCallbackDone; + assert.equal(captured, true); + transition.finish(); + assert.equal(await rendered, true); + assert.equal(changes, 1); + rendering.dispose(); + } finally { + Syntax.highlight = originalHighlight; + globalThis.Image = originalImage; + delete globalThis.document; + } +}); + function animatedSlide() { const slide = new SlideElement(` slide.anime(({utils}) => utils.set(slide.element.camera, {zoom: 3})); diff --git a/context/getting-started.md b/context/getting-started.md index cb9ed00..751db23 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -112,6 +112,36 @@ Relative images embedded in Markdown resolve from the Markdown file's directory, Presently loads each discovered stylesheet once in deterministic presentation order. Directory styles are loaded from parent to child before the matching slide sidecar, so more specific styles naturally appear later in the cascade. The same stylesheets are used by the display, presenter, recorder, playback, and export interfaces. +### Background Images + +Add a background image behind any slide template using frontmatter: + +``` markdown +--- +template: title +background: images/mountains.jpg +background-size: cover +--- + +# Welcome +``` + +The background fills the available slide surface, including space outside the 16:9 content area. Content keeps its 16:9 layout. Images are centred and do not repeat. + +Relative image paths resolve from the slide file's directory, just like Markdown images. Root-relative paths and external URLs are also supported. + +`background-size` accepts: + +| Value | Behaviour | +|---|---| +| `cover` (default) | Preserves proportions and fills the surface, cropping the image when necessary. | +| `contain` | Preserves proportions and shows the whole image, potentially leaving uncovered space. | +| `auto` | Uses the image's natural size. | + +Missing or unrecognised sizes use `cover`. An explicit background image replaces the template's background image or gradient; uncovered space shows the underlying slide background colour. Slides without a background image retain their template's appearance. + +Backgrounds appear in the display, presenter previews, recorder, playback, and PDF export. A preview or export with a different aspect ratio can crop the image differently from the audience display. + ### Running the Presentation Start the server from your presentation directory: diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index cb9ed00..751db23 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -112,6 +112,36 @@ Relative images embedded in Markdown resolve from the Markdown file's directory, Presently loads each discovered stylesheet once in deterministic presentation order. Directory styles are loaded from parent to child before the matching slide sidecar, so more specific styles naturally appear later in the cascade. The same stylesheets are used by the display, presenter, recorder, playback, and export interfaces. +### Background Images + +Add a background image behind any slide template using frontmatter: + +``` markdown +--- +template: title +background: images/mountains.jpg +background-size: cover +--- + +# Welcome +``` + +The background fills the available slide surface, including space outside the 16:9 content area. Content keeps its 16:9 layout. Images are centred and do not repeat. + +Relative image paths resolve from the slide file's directory, just like Markdown images. Root-relative paths and external URLs are also supported. + +`background-size` accepts: + +| Value | Behaviour | +|---|---| +| `cover` (default) | Preserves proportions and fills the surface, cropping the image when necessary. | +| `contain` | Preserves proportions and shows the whole image, potentially leaving uncovered space. | +| `auto` | Uses the image's natural size. | + +Missing or unrecognised sizes use `cover`. An explicit background image replaces the template's background image or gradient; uncovered space shows the underlying slide background colour. Slides without a background image retain their template's appearance. + +Backgrounds appear in the display, presenter previews, recorder, playback, and PDF export. A preview or export with a different aspect ratio can crop the image differently from the audience display. + ### Running the Presentation Start the server from your presentation directory: diff --git a/lib/presently/slide.rb b/lib/presently/slide.rb index 9d7fd0c..1cbf918 100644 --- a/lib/presently/slide.rb +++ b/lib/presently/slide.rb @@ -272,24 +272,31 @@ def expand_includes!(document, base_dir, root, depth: 0) # @parameter source_path [String] The Markdown source path. # @parameter root [String] The presentation asset root. def rewrite_image_urls!(document, source_path, root) + document.walk do |node| + node.url = resolve_image_url(node.url, source_path, root) if node.type == :image + end + end + + # Resolve an image URL relative to its source file within the presentation. + # @parameter url [String] The image URL. + # @parameter source_path [String] The source file path. + # @parameter root [String] The presentation asset root. + # @returns [String] The resolved URL, preserving absolute URLs. + def resolve_image_url(url, source_path, root) + reference = Protocol::URL[url] + return url unless reference.is_a?(Protocol::URL::Relative) + return url if reference.path.empty? || reference.path.absolute? + directory = File.dirname(File.expand_path(source_path)) root = File.expand_path(root) - return unless directory == root || directory.start_with?(root + File::SEPARATOR) + return url unless directory == root || directory.start_with?(root + File::SEPARATOR) relative_directory = directory.delete_prefix(root).delete_prefix(File::SEPARATOR) base_path = Stylesheet::PREFIX base_path += Stylesheet.encode_path(relative_directory) + "/" unless relative_directory.empty? base_url = Protocol::URL::Relative.new(base_path) - document.walk do |node| - next unless node.type == :image - - url = Protocol::URL[node.url] - next unless url.is_a?(Protocol::URL::Relative) - next if url.path.empty? || url.path.absolute? - - node.url = (base_url + url).to_s - end + (base_url + reference).to_s end end @@ -348,6 +355,22 @@ def template @front_matter&.fetch("template", "default") || "default" end + # The background image URL, resolved relative to this slide's source file. + # @returns [String | Nil] The URL, or `nil` when no image is specified. + def background + value = @front_matter&.fetch("background", nil) + return unless value.is_a?(String) && !value.strip.empty? + + Parser.resolve_image_url(value, source_path, @presentation.root) + end + + # How the background image is sized within the full slide surface. + # @returns [String] `"cover"`, `"contain"`, or `"auto"`, defaulting to `"cover"`. + def background_size + value = @front_matter&.fetch("background-size", nil) + ["cover", "contain", "auto"].include?(value) ? value : "cover" + end + # The expected duration of this slide in seconds. # Negative, invalid, or non-finite values are treated as zero. # @returns [Float] A finite duration of at least `0.0`, defaulting to `0.0` when unspecified or null. diff --git a/lib/presently/slide_renderer.rb b/lib/presently/slide_renderer.rb index 6835cb2..ecd21df 100644 --- a/lib/presently/slide_renderer.rb +++ b/lib/presently/slide_renderer.rb @@ -50,7 +50,14 @@ def render(builder, slide, extra_class: nil) classes = classes.compact.join(" ") path = Stylesheet.encode_path(slide.path) - builder.tag(:div, class: "slide-surface", data: {template: slide.template}) do + background = slide.background + if background + # Escape the CSS string independently of the builder's HTML escaping: + url = background.gsub(/["\\\x00-\x1f\x7f]/){|character| "\\#{character.ord.to_s(16)} "} + style = %(background-image: url("#{url}"); background-size: #{slide.background_size};) + end + + builder.tag(:div, class: "slide-surface", data: {template: slide.template, background: background}, style: style) do builder.tag(:div, class: classes, data: {template: slide.template}, "data-slide-path": path) do builder.raw(html) diff --git a/public/_components/.manifest.json b/public/_components/.manifest.json index f1eb798..2044999 100644 --- a/public/_components/.manifest.json +++ b/public/_components/.manifest.json @@ -11,11 +11,11 @@ "version": null, "source": ".", "files": { - "Presently.js": "552a41737db0e67cde2f4555cf686ddb6a5de8da28c64d0404b8d7b2c10152c9", + "Presently.js": "1dc1a68b5a16bf0032d6f57bfb405dff47147fee605ce23172986926475fdd4d", "Presently/CodeFocus.js": "05fb95308f012cab92655671050279149fc4aa683450932e27fc77bd51f8ce29", "Presently/Scripts.js": "c2a005e2dc7e938a5ca32b7a2177331ad0cea6aa9e7496ffe2810b9561291726", "Presently/Slide.js": "0d67e83eff558aee8ae829a2e526dc4d583f9c0753db156c531b57e32d271ead", - "Presently/SlideRendering.js": "935ce8ceb65800146cfdc734d93d764268d6077821199721b6d1bcd31b596113" + "Presently/SlideRendering.js": "4a5e20393795fea7ebb198c7e10e074ec8f4c98fba6f53a96a67f1891723fc85" } }, "@socketry/syntax": { @@ -126,5 +126,5 @@ } } }, - "digest": "b12ebf93a8c386fdd2b7861c43c602a1313b48decab5521775abc03c6e8ff2a4" + "digest": "2d6f6dd1fb5ef406a10a631dcd3f52899d13ff0daef0452b207b83184eab7194" } diff --git a/public/_components/@socketry/presently/Presently.js b/public/_components/@socketry/presently/Presently.js index 8002eec..f401c17 100644 --- a/public/_components/@socketry/presently/Presently.js +++ b/public/_components/@socketry/presently/Presently.js @@ -1,4 +1,4 @@ export {Slide, SlideBuilder, SlideContext, SlideElements} from './Presently/Slide.js'; export {applyCodeFocus} from './Presently/CodeFocus.js'; -export {SlideRendering} from './Presently/SlideRendering.js'; +export {SlideRendering, prepareSlideBackgrounds} from './Presently/SlideRendering.js'; export {runAllSlideScripts, runScript} from './Presently/Scripts.js'; diff --git a/public/_components/@socketry/presently/Presently/SlideRendering.js b/public/_components/@socketry/presently/Presently/SlideRendering.js index 71b62a3..bfe5b2f 100644 --- a/public/_components/@socketry/presently/Presently/SlideRendering.js +++ b/public/_components/@socketry/presently/Presently/SlideRendering.js @@ -4,8 +4,19 @@ import {runScript} from './Scripts.js'; const SLIDE_CHANGE_EVENT = 'presently:slide:change'; -async function prepareSlides() { - await Syntax.highlight(); +// Wait for background images to decode before capturing a transition or export. +// A missing or invalid image must not prevent the slides from becoming ready. +export async function prepareSlideBackgrounds(root = document) { + const urls = new Set(Array.from(root.querySelectorAll('.slide-surface[data-background]'), surface => surface.dataset.background)); + await Promise.all(Array.from(urls, async url => { + const image = new Image(); + image.src = url; + await image.decode().catch(() => {}); + })); +} + +async function prepareSlides(view) { + await Promise.all([Syntax.highlight(), prepareSlideBackgrounds(view)]); await applyCodeFocus(); } @@ -42,7 +53,7 @@ export class SlideRendering { if (slide) this.#slides.push(slide); }); - await prepareSlides(); + await prepareSlides(this.#view); return !this.#disposed; } diff --git a/public/_static/slides.css b/public/_static/slides.css index 9e56d3d..43c3b17 100644 --- a/public/_static/slides.css +++ b/public/_static/slides.css @@ -17,6 +17,9 @@ place-items: center; container-type: size; background: var(--slide-background); + background-size: cover; + background-position: center; + background-repeat: no-repeat; } .slide { @@ -159,7 +162,7 @@ /* Section template */ .slide-surface[data-template="section"] { - background: linear-gradient(135deg, var(--accent), var(--surface-light)); + background-image: linear-gradient(135deg, var(--accent), var(--surface-light)); } .section-template { diff --git a/public/export.js b/public/export.js index 9258796..bdba155 100644 --- a/public/export.js +++ b/public/export.js @@ -1,4 +1,4 @@ -import {applyCodeFocus, runAllSlideScripts} from '@socketry/presently'; +import {applyCodeFocus, runAllSlideScripts, prepareSlideBackgrounds} from '@socketry/presently'; import Syntax from '@socketry/syntax'; // Wait for two animation frames, ensuring the browser has processed all pending @@ -9,15 +9,16 @@ function waitForRender() { } async function main() { - // 1. Kick off syntax highlighting and font loading concurrently. + // 1. Kick off syntax highlighting, background decoding, and font loading concurrently. // Slide scripts are synchronous and don't depend on either, so run them now. const syntaxDone = Syntax.highlight(); + const backgroundsDone = prepareSlideBackgrounds(); // 2. Run slide scripts (synchronous in export mode — sets visibility instantly). runAllSlideScripts({animated: false}); - // 3. Wait for syntax and fonts to finish before applying focus. - await Promise.all([syntaxDone, document.fonts.ready]); + // 3. Wait for syntax, backgrounds, and fonts to finish before applying focus. + await Promise.all([syntaxDone, backgroundsDone, document.fonts.ready]); // 4. Apply code focus now that syntax-code elements are ready. await applyCodeFocus(); diff --git a/test/presently/slide.rb b/test/presently/slide.rb index 278dc78..ee9379b 100644 --- a/test/presently/slide.rb +++ b/test/presently/slide.rb @@ -31,6 +31,65 @@ def load_slide(path) end end + with "#background" do + let(:path) {File.join(root, "010-section", "010-background.md")} + let(:presentation) {Presently::Presentation.new(root)} + + before do + FileUtils.mkdir_p(File.dirname(path)) + end + + { + "images/mountains.jpg" => "/_slides/010-section/images/mountains.jpg", + "../shared/mountains.jpg?size=large#preview" => "/_slides/shared/mountains.jpg?size=large#preview", + "/images/mountains.jpg" => "/images/mountains.jpg", + "https://example.com/mountains.jpg" => "https://example.com/mountains.jpg", + "//example.com/mountains.jpg" => "//example.com/mountains.jpg", + }.each do |source, expected| + it "resolves #{source}" do + File.write(path, {"background" => source}.to_yaml + "---\n# Slide\n") + + expect(presentation.slides.first.background).to be == expected + end + end + + [nil, "", " ", false, 42, {}].each do |value| + it "ignores a background of #{value.inspect}" do + File.write(path, {"background" => value}.to_yaml + "---\n# Slide\n") + + expect(presentation.slides.first.background).to be_nil + end + end + + it "has no background by default" do + expect(slide.background).to be_nil + end + end + + with "#background_size" do + let(:path) {File.join(root, "slide.md")} + + ["cover", "contain", "auto"].each do |size| + it "accepts #{size}" do + File.write(path, {"background-size" => size}.to_yaml + "---\n# Slide\n") + + expect(load_slide(path).background_size).to be == size + end + end + + [nil, "", "stretch", "cover; color: red", false, 42, []].each do |value| + it "defaults to cover for #{value.inspect}" do + File.write(path, {"background-size" => value}.to_yaml + "---\n# Slide\n") + + expect(load_slide(path).background_size).to be == "cover" + end + end + + it "defaults to cover when omitted" do + expect(slide.background_size).to be == "cover" + end + end + with "#duration" do let(:path) {File.join(root, "slide.md")} diff --git a/test/presently/slide_renderer.rb b/test/presently/slide_renderer.rb index afdf9a8..11f5753 100644 --- a/test/presently/slide_renderer.rb +++ b/test/presently/slide_renderer.rb @@ -185,6 +185,36 @@ def load_slide(path) html = subject.new(templates: presentation.templates).render_to_html(presentation.slides.first) expect(html).to be(:include?, 'class="slide-surface"') + expect(html).not.to be(:include?, "background-image:") + expect(html).not.to be(:include?, "data-background=") + end + + ["default", "section"].each do |template| + it "renders a full-surface background for the #{template} template" do + File.write(path, {"template" => template, "background" => "images/mountains.jpg"}.to_yaml + "---\n# Slide\n") + presentation = Presently::Presentation.load(root) + html = subject.new(templates: presentation.templates).render_to_html(presentation.slides.first) + + expect(html).to be(:include?, 'data-background="/_slides/images/mountains.jpg"') + expect(html).to be(:include?, "background-image: url("/_slides/images/mountains.jpg"); background-size: cover;") + end + end + + it "renders the requested background size" do + File.write(path, {"background" => "image.jpg", "background-size" => "contain"}.to_yaml + "---\n# Slide\n") + presentation = Presently::Presentation.load(root) + html = subject.new.render_to_html(presentation.slides.first) + + expect(html).to be(:include?, "background-size: contain;") + end + + it "escapes background URLs as both CSS strings and HTML attributes" do + url = "/images/a\"b\\c.jpg?x=1&y=2" + File.write(path, {"background" => url}.to_yaml + "---\n# Slide\n") + presentation = Presently::Presentation.load(root) + html = subject.new.render_to_html(presentation.slides.first) + + expect(html).to be(:include?, 'background-image: url("/images/a\22 b\5c c.jpg?x=1&y=2"); background-size: cover;') end it "renders an H1 as the diagram title" do