PresentlySourcePresentlyExport

class Export

Renders a self-contained, print-ready HTML page containing all slides for PDF export.

All slides are rendered in a single page with CSS break-after: page, so a single WebDriver print() call produces a multi-page PDF without any merging step.

Definitions

PageSize

Holds slide canvas and notes panel dimensions, and converts between CSS pixels and centimetres for WebDriver print (96 px/inch × 2.54 cm/inch).

Implementation

PageSize = Struct.new(:slide_width_px, :slide_height_px, :notes_height_px, keyword_init: true) do
	PX_PER_CM = 96.0 / 2.54
	
	def slide_width_cm  = (slide_width_px  / PX_PER_CM).round(4)
	def slide_height_cm = (slide_height_px / PX_PER_CM).round(4)
	def notes_height_cm = (notes_height_px / PX_PER_CM).round(4)
end

def self.options_from_query(query)

Parse export options from a URL query string.

Signature

parameter query String | Nil

The raw query string, e.g. "notes=true&speaker=true".

returns Hash

Options suitable for passing to .new.

Implementation

def self.options_from_query(query)
	return {} unless query
	
	params = URI.decode_www_form(query).to_h
	
	page_size = PageSize.new(
		slide_width_px:  (params["slide_width_px"]  || PageSize::DEFAULT.slide_width_px).to_i,
		slide_height_px: (params["slide_height_px"] || PageSize::DEFAULT.slide_height_px).to_i,
		notes_height_px: (params["notes_height_px"] || PageSize::DEFAULT.notes_height_px).to_i,
	)
	
	{
		notes:     params["notes"]   != "false",
		speaker:   params["speaker"] != "false",
		timing:    params["timing"]  != "false",
		page_size: page_size,
	}
end

def initialize(presentation:, page_size: PageSize::DEFAULT, notes: true, speaker: true, timing: true)

Signature

parameter presentation Presentation

The presentation to export.

parameter page_size PageSize

Slide canvas and notes panel dimensions.

parameter notes Boolean

Whether to include presenter notes below each slide.

parameter speaker Boolean

Whether to include the speaker name.

parameter timing Boolean

Whether to include per-slide timing information.

Implementation

def initialize(presentation:, page_size: PageSize::DEFAULT, notes: true, speaker: true, timing: true)
	@presentation = presentation
	@page_size    = page_size
	@notes        = notes
	@speaker      = speaker
	@timing       = timing
	@renderer     = SlideRenderer.new
end

attr :page_size

Signature

attribute PageSize

The slide canvas and notes panel dimensions.

attr :notes

Signature

attribute Boolean

Whether presenter notes are included.

attr :speaker

Signature

attribute Boolean

Whether speaker names are included.

attr :timing

Signature

attribute Boolean

Whether slide timing is included.

def render_slide(slide)

Render a single slide to an HTML string.

Signature

parameter slide Slide

The slide to render.

returns XRB::MarkupString

Implementation

def render_slide(slide)
	@renderer.render_to_html(slide)
end

def render_notes(slide, index)

Render the notes panel for a single slide.

Signature

parameter slide Slide

The slide to render notes for.

parameter index Integer

The 1-based slide index.

returns XRB::MarkupString

Implementation

def render_notes(slide, index)
	builder = XRB::Builder.new
	
	# Meta strip — mirrors the presenter view's timing bar.
	builder.tag(:div, class: "export-meta") do
		builder.tag(:span, class: "export-slide-number") do
			builder.text("Slide #{index} of #{@presentation.slide_count}")
		end
		
		builder.tag(:span, class: "export-filename") do
			builder.tag(:code){builder.text(File.basename(slide.path))}
		end
		
		if @timing
			elapsed = expected_time_at(index - 1)
			builder.tag(:span, class: "export-elapsed") do
				builder.text("Elapsed: #{format_duration(elapsed)}")
			end
			builder.tag(:span, class: "export-duration") do
				builder.text("Slide: #{format_duration(slide.duration)}")
			end
		end
		
		if @speaker && slide.speaker
			builder.tag(:span, class: "export-speaker") do
				builder.tag(:span, class: "speaker-label"){builder.text("🎤")}
				builder.text(" #{slide.speaker}")
			end
		end
	end
	
	# Notes panel — mirrors the presenter view's .notes section.
	builder.tag(:div, class: "notes") do
		builder.tag(:div, class: "notes-content") do
			if slide.notes && !slide.notes.empty?
				builder.raw(slide.notes.to_html)
			else
				builder.tag(:p, class: "no-notes"){builder.text("No presenter notes for this slide.")}
			end
		end
	end
	
	XRB::MarkupString.raw(builder.to_s)
end

def format_duration(seconds)

Format a duration in seconds as MM:SS.

Signature

parameter seconds Integer

Duration in seconds.

returns String

Implementation

def format_duration(seconds)
	minutes = seconds / 60
	secs    = seconds % 60
	format("%02d:%02d", minutes, secs)
end

def expected_time_at(index)

Calculate the expected elapsed time at the start of the given slide index.

Signature

parameter index Integer

Zero-based slide index.

returns Integer

Elapsed seconds up to (but not including) that slide.

Implementation

def expected_time_at(index)
	@presentation.slides.first(index).sum(&:duration)
end

def call

Render the full export page to an HTML string.

Signature

returns String

Implementation

def call
	TEMPLATE.to_string(self)
end

def slides

The slides in the presentation.

Signature

returns Array(Slide)

Implementation

def slides
	@presentation.slides
end